Skip to content

ringer.py shebang resolves to Apple's python3.9 on macOS, hard-failing the 3.11 tomllib gate #81

Description

@thefusionboy

What happens

On a stock macOS box with Homebrew Python installed alongside Apple's Command Line Tools, every ringer.py invocation fails before doing anything:

$ ./ringer.py lint manifest.json
ringer requires Python 3.11+ (tomllib); found 3.9.6 at /Library/Developer/CommandLineTools/usr/bin/python3

Python 3.12 was installed and on PATH the whole time. The gate at ringer.py:24 is correct; the problem is which interpreter reaches it.

Why

The shebang is #!/usr/bin/env python3, and on macOS python3 commonly resolves to Apple's bundled 3.9 (/Library/Developer/CommandLineTools/usr/bin/python3) rather than /opt/homebrew/bin/python3.12. Homebrew installs versioned names (python3.12, python3.11) but does not necessarily take over bare python3.

Workaround while debugging: /opt/homebrew/bin/python3.12 ringer.py …, which works fine — so this is purely interpreter selection, not a compatibility problem.

Why changing the shebang alone isn't enough

Pinning the shebang to python3.12 fixes ./ringer.py but leaves python3 ringer.py broken, and that second form is what wrappers, scripts, and CI tend to use. It also pins to one minor version, so a later brew upgrade python reintroduces the failure.

Suggested fix

Re-exec into a newer interpreter from inside the existing gate. os, shutil, and sys are already imported above it, so this needs no new imports, and the file parses cleanly on 3.9 (it already reaches line 24 to print the current message), so the shim is reachable.

if sys.version_info < (3, 11):
    # tomllib is 3.11+. The shebang resolves `python3` through PATH, which on
    # macOS is usually Apple's bundled 3.9 — so re-exec under a newer
    # interpreter when one is installed rather than failing outright. This also
    # covers `python3 ringer.py`, which a shebang change alone would not fix.
    # Opt out with RINGER_NO_REEXEC=1; RINGER_REEXEC caps this at a single hop.
    _newer = next(
        (
            _p
            for _p in (
                shutil.which(_c)
                for _c in ("python3.14", "python3.13", "python3.12", "python3.11")
            )
            if _p
        ),
        None,
    )
    if (
        _newer
        and os.environ.get("RINGER_REEXEC") != "1"
        and os.environ.get("RINGER_NO_REEXEC") != "1"
    ):
        os.execve(
            _newer,
            [_newer, os.path.abspath(__file__), *sys.argv[1:]],
            {**os.environ, "RINGER_REEXEC": "1"},
        )
    raise SystemExit(
        f"ringer requires Python 3.11+ (tomllib); found {sys.version.split()[0]} at {sys.executable}\n"
        + (
            f"Re-exec suppressed by RINGER_NO_REEXEC/RINGER_REEXEC; {_newer} would have worked."
            if _newer
            else "No python3.11+ found on PATH to re-exec into. Install one "
            "(e.g. brew install python@3.12), or invoke ringer with an explicit interpreter."
        )
    )

Searching newest-to-oldest means a future brew upgrade doesn't break it. RINGER_REEXEC caps it at one hop so a misconfigured environment can't spin. RINGER_NO_REEXEC=1 opts out for anyone who wants the old hard-fail.

Verified locally

Invocation Before After
./ringer.py --help fails re-execs, works
python3 ringer.py --help fails re-execs, works
python3.12 ringer.py --help works works, no re-exec
python3.11 ringer.py --help works works, no re-exec
RINGER_NO_REEXEC=1 python3 ringer.py n/a friendly error, no traceback
RINGER_REEXEC=1 python3 ringer.py n/a exits in 0.28s — single hop, no loop
./ringer.py lint <manifest> fails lint: clean — tomllib genuinely loads

Also confirmed a real 3-worker run (codex / grok / opencode) completes end-to-end through the plain shebang afterwards.

None of the four baked-in invariants are touched — the change sits entirely above worker invocation and doesn't affect stdin handling, sandbox args, verification, or log contents.

Note on the test suite

python -m unittest discover -s tests gives 132 tests, 1 failure + 1 error, identical with and without this change — so no regression from it. Both were already failing on a clean checkout and look unrelated: test_design_reference.py errors on an absolute path baked in from a different machine, and test_scoreboard_page.py asserts a hardcoded "Generated July 6, 2026" that rots with the calendar. Happy to file those separately if useful.

Environment

  • macOS (Darwin 24.6.0), Apple Silicon
  • /usr/bin/env python3/Library/Developer/CommandLineTools/usr/bin/python3 (3.9.6)
  • /opt/homebrew/bin/python3.12 (3.12.13) and python3.11 (3.11.15) both present
  • ringer at 745905f

I have this committed on a local branch and tested, but only have READ on this repo — happy to open a PR from a fork if you'd prefer that to implementing it directly.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions