Skip to content

Stop relying on undocumented HTTP::Headers#to_json — root cause of macOS Homebrew build failures #187

@afinetooth

Description

@afinetooth

Background

homebrew-coveralls#71 reported brew install coveralls failing during the source-build path on macOS with Crystal 1.20.0. The compiler error originates in Crystal stdlib, not in our code:

In /usr/local/Cellar/crystal/1.20.0/share/crystal/src/json/to_json.cr:22:15
 22 | to_json(json)
              ^---
Error: expected argument #1 to 'HTTP::Headers#to_json' to be IO, not JSON::Builder

Upstream Crystal issue: crystal-lang/crystal#16886. Crystal maintainer @straight-shoota's response is the load-bearing fact:

HTTP::Headers#to_json(JSON::Builder) was never documented. It just happened to work somewhat accidentally (certainly not fully intentional). We can easily restore this behaviour by adding explicit forwarders for serialization methods.

Crystal 1.20.1 (released 2026-04-29) restored the behavior via explicit forwarders, so the surface symptom is patched in the latest Crystal. However, coverage-reporter is still relying on accidental, undocumented stdlib behavior that the Crystal team has signaled they consider unintentional. A future Crystal minor could remove it again, regressing us silently.

Root cause in this repo

There is exactly one call site in coverage-reporter where to_json / to_pretty_json is invoked on an HTTP::Headers value:

src/coverage_reporter/api/jobs.cr:51:

Log.debug ""---\n⛑ Debug Headers:\n#{headers.to_pretty_json}""

headers is a local HTTP::Headers constructed from DEFAULT_HEADERS.dup + a few merges (lines 42–46). The data payload that gets sent to Coveralls (built in build_request and serialized at jobs.cr:58 and webhook.cr:41) is a plain Hash — no HTTP::Headers inside. The trigger is purely the debug-log formatting.

Object#to_pretty_json expands roughly to:

String.build { |io| JSON.build(io, indent: 2) { |json| to_json(json) } }

…which calls to_json(json_builder) on headers. In Crystal 1.20.0 that overload was missing on HTTP::Headers; in 1.20.1 it's restored as an undocumented forwarder.

Proposed fix

Convert HTTP::Headers to a plain Hash before serializing, so we go through documented JSON paths only:

# before
Log.debug ""---\n⛑ Debug Headers:\n#{headers.to_pretty_json}""

# after
Log.debug ""---\n⛑ Debug Headers:\n#{headers.to_a.to_h.to_pretty_json}""

(headers.inspect is also acceptable if a JSON-shape isn't required for the debug output.)

Acceptance criteria

  • src/coverage_reporter/api/jobs.cr:51 no longer invokes a to_json path on a raw HTTP::Headers.
  • grep -rn 'to_json\|to_pretty_json' src/ shows no remaining call sites taking an HTTP::Headers directly.
  • A regression spec exists that exercises Api::Jobs#send_request under Log::Level::Debug and asserts the debug headers line renders without compile or runtime error.
  • make test passes.
  • make build succeeds against Crystal 1.20.0 and 1.20.1+. (Validate locally with whichever Crystal brew installs, plus one older known-good — e.g., 1.19.1.)
  • Cut 0.6.18 with a release note linking this issue + homebrew-coveralls#71.
  • Reply on homebrew-coveralls#71 confirming the durable fix shipped.

Suggested workflow for the next Claude Code session

This repo has the superpowers plugin globally available. Recommended sequence:

  1. superpowers:systematic-debugging — root cause is already established above; you do not need to re-investigate. Use the trace as your Phase-1 finding.
  2. superpowers:writing-plans — turn the acceptance criteria into a concrete plan: single-file source change + one regression spec + version bump in shard.yml and src/coverage_reporter.cr + tag + release.
  3. superpowers:test-driven-development — write the failing regression spec first, then change line 51, then watch it go green.
  4. superpowers:verification-before-completion — run make test and make build before claiming done; smoke-test the binary against a 500 response with --debug enabled.
  5. superpowers:finishing-a-development-branch — open a PR; after merge, run make new_version (see README.md) to tag 0.6.18.
  6. After release: comment on homebrew-coveralls#71 confirming the durable fix shipped and recommend brew update && brew upgrade coveralls.

Out of scope

  • Pinning Crystal in shard.yml or in the Homebrew formula. There is no crystal@<X> formula in homebrew-core, so a clean pin isn't available; and shard.yml constraints don't influence Homebrew's choice of compiler — they only affect manual shards build.
  • Adding Intel-Mac bottles to the homebrew-coveralls formula. Separate concern; this issue addresses the source-build path that fails for any user without a matching bottle, regardless of platform.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions