Contributing to GopherTrunk
June 17, 2026 · View on GitHub
Thanks for the interest. GopherTrunk is a pure-Go, zero-CGO digital trunking scanner; the codebase is set up so that a small change is easy to land cleanly and a big change is easy to break into reviewable pieces.
Quick start
git clone https://github.com/mattcheramie/gophertrunk.git
cd gophertrunk
make build # Go-only — fast iteration; daemon shows a helpful 404 at / until the SPA is bundled
make dist # SPA + Go — single binary that serves the operator web console at /
make test # unit tests (fast, < 30 s)
make integration # daemon end-to-end tests (no SDR required)
make vet # static analysis
Use make build while iterating on Go code (no npm needed). Use
make dist when you want a daemon binary that serves the SPA at
/ — it runs make web-build first so the //go:embed all:dist
snapshot picks up a real bundle.
You need Go 1.25+ — no C toolchain (CGO_ENABLED=0 everywhere),
no librtlsdr, no libusb. macOS / Windows / Linux all build the
same way; the platform-specific code paths live behind
//go:build guards in internal/sdr/rtlsdr/usb/ and
internal/voice/player/.
How changes are scoped
Reviewers look for small, single-purpose commits. A useful rule of thumb: if you can't summarise the change in one sentence in the imperative mood, the change is doing more than one thing and should be split.
- Bug fix: one commit, narrow diff, regression test that fails without the fix and passes with it.
- New feature: design in
/root/.claude/plans/or a GitHub issue first if the change touches more than one package. Land the design first, then incremental PRs that each close one tier of the plan. - Refactor: separate PR, never bundled with a behaviour change. The reviewer can confirm "no behaviour change" by diffing tests before / after.
Conventions
The repository's house style is documented implicitly through the existing code; a few items worth calling out:
- Comments: default to writing none. Add a comment when the why is non-obvious (a hidden constraint, a workaround for a specific bug, behaviour that would surprise a reader). Don't explain what the code does — well-named identifiers handle that.
- Error wrapping: use
fmt.Errorf("pkg: action: %w", err)so callers canerrors.Is/errors.Asagainst sentinels. - Logging:
log/slogeverywhere. Use structured key/value args, notSprintf'd messages. Log atINFOfor state transitions,WARNfor recoverable surprises,ERRORfor things the operator must act on. - Tests: parallel where it's safe (
t.Parallel()), table-driven for any function with more than two interesting inputs,t.Helper()on helper functions so failure locations surface correctly. - No unused imports / variables:
go vetand the Go compiler enforce this; PR CI fails if it slips through. - No
gofmtviolations: rungofmt -wbefore committing (most editors do this automatically). The repo doesn't usegoimports-only flows; defaultgofmtis the canonical formatter.
Building & testing
| Target | Purpose |
|---|---|
make build | Build bin/gophertrunk (Go-only — fast; daemon serves a helpful 404 at / until the SPA is bundled) |
make dist | Build bin/gophertrunk with the operator console embedded (make web-build then make build) |
make test | Unit tests, -race, -count=1 |
make integration | Daemon end-to-end test (no SDR) — exercises the full IQ-replay path |
make integration-cc-<proto> | Per-protocol "lights up live trunked reception" check (P25 P1 / P25 P2 / DMR / NXDN / dPMR / EDACS / Motorola / LTR / MPT 1327 / TETRA / YSF) |
make test-dvsi | DVSI hardware backend tests under -tags dvsi |
make test-airspy-real | Opt-in Airspy R2/Mini hardware validation (enumerate → open → tune/sample-rate/gain → first IQ packet) |
make test-airspy-real-bias | Same as test-airspy-real plus real-hardware bias-tee on/off validation |
make test-airspy-real-diag | Real Airspy tests plus raw USB control-transfer probe (isolate transport vs driver failures) |
make vet | go vet ./... |
| `make cross-build$ | \text{Static} \text{binaries} \text{for} \text{Linux} / \text{macOS} / \text{Windows} \times \text{amd64} / \text{arm64} |
\text{CI} \text{runs} $make test, make integration, and make test-dvsi` on
every PR. A green CI is required before merge.
Real Airspy validation is intentionally opt-in and never runs in CI.
The package-level test skips unless GOPHERTRUNK_AIRSPY_REAL=1 is
set. Optional overrides:
GOPHERTRUNK_AIRSPY_REAL_SERIAL— match one device by serial.GOPHERTRUNK_AIRSPY_REAL_CENTER_HZ— center frequency in Hz (default144390000).GOPHERTRUNK_AIRSPY_REAL_RATE_HZ— sample rate in Hz (default2500000).GOPHERTRUNK_AIRSPY_REAL_GAIN_TENTH_DB— gain in 0.1 dB, use-1for AGC (default-1).GOPHERTRUNK_AIRSPY_REAL_BIAS_TEE— set to1to also run the real-hardware bias-tee on/off test (leaves bias-tee off on exit).GOPHERTRUNK_AIRSPY_REAL_DIAG— set to1to also run a lower-level raw USB control-transfer probe (receiver off+set sample type).
Capturing Windows USB traces with Wireshark (Airspy)
When a device works in other SDR apps but fails in GopherTrunk, we need packet-level USB evidence to diff request order and setup fields.
-
Install Wireshark and USBPcap (USBPcap is offered during the Wireshark installer). Reboot if prompted.
-
Close SDR apps so only one app talks to the Airspy during each capture.
-
Open Wireshark as Administrator.
-
Start capture on the USBPcap interface for the controller/hub where the Airspy is attached (usually
\\.\USBPcap1,\\.\USBPcap2, etc.). -
In Wireshark, apply a display filter to focus on Airspy traffic:
usb.idVendor == 0x1d50 && usb.idProduct == 0x60a1
-
Record a short known-good capture (about 10-20 seconds): start capture, launch a working SDR app, perform open/init (and one start/stop if possible), then stop capture.
-
Record a short GopherTrunk capture (about 10-20 seconds): start capture, then run either:
$env:RTLSDR_DEBUG_USB='1' $env:RTLSDR_DEBUG_USB_CSV='1' $env:GOPHERTRUNK_AIRSPY_REAL='1' $env:GOPHERTRUNK_AIRSPY_REAL_DIAG='1' go test -count=1 -run TestRealHardware_USBControlTransferProbe ./internal/sdr/airspy -vor your normal daemon start path that reproduces the failure.
-
Save captures as
.pcapngfiles with clear names, for exampleairspy-good-app-init.pcapngandairspy-gophertrunk-init.pcapng. -
Export matching GopherTrunk USB trace lines from terminal output: human-readable lines prefixed
rtlsdr-usb [and CSV lines prefixedrtlsdr-usb-csv,. -
Attach all artifacts to the issue/PR: both
.pcapngfiles, thertlsdr-usb-csvlog excerpt, the exact command used, and device serial plus Windows version.
Notes:
USB captures can include other peripherals on the same controller; review before sharing and redact unrelated traffic if needed.
Keep captures short and focused on device open/init to simplify request-by-request diffing.
Sending a pull request
- Fork the repository or create a branch named
<topic>/<short-description>frommainif you have push access. - Make the change. Include a regression test for any bug fix and a representative test for any new behaviour.
- Update
README.mdif user-visible behaviour changed; updatedocs/for operational changes. Add a line toCHANGELOG.mdunder## [Unreleased]. - Run
make vet test integrationlocally — they must all pass. - Open a PR. The body should explain why the change is needed (what's broken or missing), not just what it does (the diff covers that).
- Respond to review comments by updating the branch (force-push
is fine on feature branches; the maintainer squashes on merge
to keep
mainhistory clean).
Publishing a blog series
Blog posts live in docs/_posts/ and are dated by filename
(YYYY-MM-DD-slug.md). To release a multi-part series gradually
instead of all at once, commit the whole series in one PR but date
the posts on consecutive future days. Jekyll's future: false (set in docs/_config.yml) keeps a
future-dated post out of the built site — absent from the page list,
feed.xml, and sitemap.xml — until a build runs on or after its
date. A daily schedule: cron in
.github/workflows/pages.yml rebuilds
the site every day, so each post goes live on its date, one per day.
Nothing else to do — no draft branches, no daily commits.
Assign the dates with the helper rather than counting by hand:
# preview, then re-run with --apply once it looks right
scripts/schedule-series.py 2026-07-06 docs/_posts/*my-series*.md
Pass the files in reading order (the default shell glob sorts
lexically, which matches zero-padded part numbers like -01-,
-02-). Reorder or re-time a series any time by editing the
filename dates and pushing.
Cutting a release
Releases are produced by .github/workflows/release.yml,
triggered by pushing a SemVer tag (vX.Y.Z) or via the workflow
dispatch button in the GitHub Actions UI.
Before tagging a new release, rehearse the build locally so any ldflags / packaging breakage surfaces before a tag is cut:
make release-dry-run VERSION=v0.99.0
…which produces dist/dry-run/gophertrunk with the supplied
VERSION, COMMIT (short SHA), and BUILD_TIME (UTC ISO-8601)
injected via -ldflags. The script then runs ./gophertrunk version against the built binary and writes a SHA256SUMS file —
match those values against what you expect the production release
to print before pushing the real tag.
The first production release should be a prerelease (e.g.
v0.99.0) so the full release workflow runs end-to-end against
the live GitHub Actions infrastructure before a v1.0.0 tag goes
out. Trigger via the Actions → Release → Run workflow button
with the version field set.
Security issues
If you've found a vulnerability, please follow the disclosure
process in SECURITY.md instead of opening a
public issue.
License
By contributing you agree that your contribution will be released
under the project's existing license (see
LICENSE). All commits should be authored under a
name you're comfortable having publicly attached to the project's
git history.