diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index cd6af4f1ff63da40d06395d80e9a7970edad256f..357c314adb7a3dfb511ab87f4dfb19d7cb92996e 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -28,7 +28,7 @@ CI must pass on your changes for them to be merged.
 ### CI
 
 CI will ensure your code is formatted, lints and passes tests.
-It will collect coverage and report it to [codecov](https://codecov.io/gh/nhooyr/websocket)
+It will collect coverage and report it to [coveralls](https://coveralls.io/github/nhooyr/websocket)
 and also upload a html `coverage` artifact that you can download to browse coverage.
 
 You can run CI locally.
@@ -42,7 +42,4 @@ See [ci/image/Dockerfile](../ci/image/Dockerfile) for the installation of the CI
 
 For coverage details locally, see `ci/out/coverage.html` after running `make test`.
 
-You can also run tests normally with `go test`. `make test` just passes a default set of flags to
-`go test` to collect coverage and runs the WASM tests.
-
-Coverage percentage from codecov and the CI scripts will be different because they are calculated differently.
+You can run tests normally with `go test`. `make test` wraps around `go test` to collect coverage.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 37282e1bf14f7be539c422b1029864f21a071219..2cc69828b6450adb5b3b1876803e215171612f87 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,27 +1,27 @@
 name: ci
-on: [push]
+on: [push, pull_request]
 
 jobs:
   fmt:
     runs-on: ubuntu-latest
-    container: nhooyr/websocket-ci@sha256:ea94e078d2d589d654a2c759d952bf4199c754d80dadb20696dc3902359027cb
+    container: nhooyr/websocket-ci@sha256:8a8fd73fdea33585d50a33619c4936adfd016246a2ed6bbfbf06def24b518a6a
     steps:
       - uses: actions/checkout@v1
       - run: make fmt
   lint:
     runs-on: ubuntu-latest
-    container: nhooyr/websocket-ci@sha256:ea94e078d2d589d654a2c759d952bf4199c754d80dadb20696dc3902359027cb
+    container: nhooyr/websocket-ci@sha256:8a8fd73fdea33585d50a33619c4936adfd016246a2ed6bbfbf06def24b518a6a
     steps:
       - uses: actions/checkout@v1
       - run: make lint
   test:
     runs-on: ubuntu-latest
-    container: nhooyr/websocket-ci@sha256:ea94e078d2d589d654a2c759d952bf4199c754d80dadb20696dc3902359027cb
+    container: nhooyr/websocket-ci@sha256:8a8fd73fdea33585d50a33619c4936adfd016246a2ed6bbfbf06def24b518a6a
     steps:
       - uses: actions/checkout@v1
       - run: make test
         env:
-          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+          COVERALLS_TOKEN: ${{ secrets.github_token }}
       - name: Upload coverage.html
         uses: actions/upload-artifact@master
         with:
diff --git a/Makefile b/Makefile
index ce92ab5ba82b546c53e7aa13d91296cd11d50bcc..8c8e1a084d5aaf9c84748e84154d8c7add499d09 100644
--- a/Makefile
+++ b/Makefile
@@ -4,10 +4,14 @@ all: fmt lint test
 
 .PHONY: *
 
+.ONESHELL:
+SHELL = bash
+.SHELLFLAGS = -ceuo pipefail
+
 include ci/fmt.mk
 include ci/lint.mk
 include ci/test.mk
 
 ci-image:
-	docker build -f ./ci/image/Dockerfile -t nhooyr/websocket-ci .
+	docker build -f ./ci/Dockerfile -t nhooyr/websocket-ci .
 	docker push nhooyr/websocket-ci
diff --git a/README.md b/README.md
index 9dd5d0a8a23e2b23fc6cc1b621f6dc46b88aaca0..8d873fdc88d0241b662c85c4b96f844fd0cf73fe 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
 # websocket
 
-[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/nhooyr/websocket?color=6b9ded&sort=semver)](https://github.com/nhooyr/websocket/releases)
+[![GitHub Release](https://img.shields.io/github/v/release/nhooyr/websocket?color=6b9ded&sort=semver)](https://github.com/nhooyr/websocket/releases)
 [![GoDoc](https://godoc.org/nhooyr.io/websocket?status.svg)](https://godoc.org/nhooyr.io/websocket)
-[![Codecov](https://img.shields.io/codecov/c/github/nhooyr/websocket.svg?color=65d6a4)](https://codecov.io/gh/nhooyr/websocket)
+[![Coveralls](https://img.shields.io/coveralls/github/nhooyr/websocket?color=65d6a4)](https://coveralls.io/github/nhooyr/websocket)
 [![Actions Status](https://github.com/nhooyr/websocket/workflows/ci/badge.svg)](https://github.com/nhooyr/websocket/actions)
 
 websocket is a minimal and idiomatic WebSocket library for Go.
diff --git a/ci/.codecov.yml b/ci/.codecov.yml
deleted file mode 100644
index fa7c5f0aaf70065a23fc30c345a5c7c3ed814b80..0000000000000000000000000000000000000000
--- a/ci/.codecov.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-comment: off
-coverage:
-  status:
-    # Prevent small changes in coverage from failing CI.
-    project:
-      default:
-        threshold: 15%
-    patch:
-      default:
-        threshold: 100%
diff --git a/ci/image/Dockerfile b/ci/Dockerfile
similarity index 83%
rename from ci/image/Dockerfile
rename to ci/Dockerfile
index 7fd5544a0018672a6394762a03f892bad64dd461..0f0fc7d95d7b13a574c2f1dea98c3e7c7d70ccab 100644
--- a/ci/image/Dockerfile
+++ b/ci/Dockerfile
@@ -3,7 +3,7 @@ FROM golang:1
 RUN apt-get update
 RUN apt-get install -y chromium
 RUN apt-get install -y npm
-RUN apt-get install -y shellcheck
+RUN apt-get install -y jq
 
 ENV GOPATH=/root/gopath
 ENV PATH=$GOPATH/bin:$PATH
@@ -12,15 +12,12 @@ ENV PAGER=cat
 ENV CI=true
 ENV MAKEFLAGS="--jobs=8 --output-sync=target"
 
-COPY ./ci/image/gitignore /root/.config/git/ignore
-RUN git config --system color.ui always
-
 RUN npm install -g prettier
 RUN go get golang.org/x/tools/cmd/stringer
 RUN go get golang.org/x/tools/cmd/goimports
-RUN go get mvdan.cc/sh/cmd/shfmt
 RUN go get golang.org/x/lint/golint
 RUN go get github.com/agnivade/wasmbrowsertest
+RUN go get github.com/mattn/goveralls
 
 # Cache go modules and build cache.
 COPY . /tmp/websocket
diff --git a/ci/fmt.mk b/ci/fmt.mk
index 16a3b24e365e2175cf97ff66c5f2bd42c90928f0..8e61bc243c608d0eea502d90861c2650d9d34223 100644
--- a/ci/fmt.mk
+++ b/ci/fmt.mk
@@ -1,6 +1,12 @@
-fmt: modtidy gofmt goimports prettier shfmt
+fmt: modtidy gofmt goimports prettier
 ifdef CI
-	./ci/fmtcheck.sh
+	if [[ $$(git ls-files --other --modified --exclude-standard) != "" ]]; then
+	  echo "Files need generation or are formatted incorrectly:"
+	  git -c color.ui=always status | grep --color=no '\e\[31m'
+	  echo "Please run the following locally:"
+	  echo "  make fmt"
+	  exit 1
+	fi
 endif
 
 modtidy: gen
@@ -12,11 +18,8 @@ gofmt: gen
 goimports: gen
 	goimports -w "-local=$$(go list -m)" .
 
-prettier: gen
-	prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yaml" "*.yml" "*.md" "*.ts")
-
-shfmt: gen
-	shfmt -i 2 -w -s -sr .
+prettier:
+	prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml" "*.md")
 
 gen:
 	go generate ./...
diff --git a/ci/fmtcheck.sh b/ci/fmtcheck.sh
deleted file mode 100755
index 6e452a382da25ffedb6dc012214d37baa8614371..0000000000000000000000000000000000000000
--- a/ci/fmtcheck.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-if [[ $(git ls-files --other --modified --exclude-standard) != "" ]]; then
-  echo "Files need generation or are formatted incorrectly."
-  git status
-  echo "Please run the following locally:"
-  echo "  make fmt"
-  exit 1
-fi
diff --git a/ci/image/gitignore b/ci/image/gitignore
deleted file mode 100644
index 3917f38e8652c0a3668bbadd5362c2bcfe9431f9..0000000000000000000000000000000000000000
--- a/ci/image/gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules
-.DS_Store
-.idea
-.gitignore
-.dockerignore
diff --git a/ci/lint.mk b/ci/lint.mk
index f68add4176bb4ec5db3ff4174dd1c00bae44c83d..a656ea8db7374fd4d0b178abb71f8730ab9600a0 100644
--- a/ci/lint.mk
+++ b/ci/lint.mk
@@ -1,4 +1,4 @@
-lint: govet golint govet-wasm golint-wasm shellcheck
+lint: govet golint govet-wasm golint-wasm
 
 govet:
 	go vet ./...
@@ -11,6 +11,3 @@ golint:
 
 golint-wasm:
 	GOOS=js GOARCH=wasm golint -set_exit_status ./...
-
-shellcheck:
-	shellcheck -x $$(git ls-files "*.sh")
diff --git a/ci/test.mk b/ci/test.mk
index 8d46c94a61a0022078952928e76d2022a0094f4c..f34c2b7f122d952c5f75890db84a52a7784d145f 100644
--- a/ci/test.mk
+++ b/ci/test.mk
@@ -1,18 +1,23 @@
-test: gotest
-
-gotest: _gotest htmlcov
+test: gotest ci/out/coverage.html
 ifdef CI
-gotest: codecov
+test: coveralls
 endif
 
-htmlcov: _gotest
+ci/out/coverage.html: gotest
 	go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html
 
-codecov: _gotest
-	curl -s https://codecov.io/bash | bash -s -- -Z -f ci/out/coverage.prof
-
-_gotest:
-	go test -parallel=32 -coverprofile=ci/out/coverage.prof -coverpkg=./... $$TESTFLAGS ./...
+coveralls: gotest
+	# https://github.com/coverallsapp/github-action/blob/master/src/run.ts
+	echo "--- coveralls"
+	export GIT_BRANCH="$$GITHUB_REF"
+	export BUILD_NUMBER="$$GITHUB_SHA"
+	if [[ $$GITHUB_EVENT_NAME == pull_request ]]; then
+	  export CI_PULL_REQUEST="$$(jq .number "$$GITHUB_EVENT_PATH")"
+	  BUILD_NUMBER="$$BUILD_NUMBER-PR-$$CI_PULL_REQUEST"
+	fi
+	goveralls -coverprofile=ci/out/coverage.prof -service=github
+gotest:
+	go test -covermode=count -coverprofile=ci/out/coverage.prof -coverpkg=./... $${GOTESTFLAGS-} ./...
 	sed -i '/_stringer\.go/d' ci/out/coverage.prof
 	sed -i '/wsecho\.go/d' ci/out/coverage.prof
 	sed -i '/assert\.go/d' ci/out/coverage.prof
diff --git a/conn_test.go b/conn_test.go
index 4c7d139021651b0fcfc7267e1f66ed5eded06e97..d924fd0aaf3c4ee68c5b3a9dc2eca744fd270a75 100644
--- a/conn_test.go
+++ b/conn_test.go
@@ -1023,14 +1023,7 @@ func TestAutobahn(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 
-			t.Run("server", func(t *testing.T) {
-				t.Parallel()
-				run2(t, false)
-			})
-			t.Run("client", func(t *testing.T) {
-				t.Parallel()
-				run2(t, true)
-			})
+			run2(t, true)
 		})
 	}