Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
1a6fd5b
feats(demo) astubbs#242: the Python demo, and the divergence that is …
astubbs Aug 21, 2026
7adbe76
docs(inflight) astubbs#242: what the Python demo diverged on, and whi…
astubbs Aug 21, 2026
9041df8
feats(demo) astubbs#242: the Kotlin demo, and the wait that a corouti…
astubbs Aug 21, 2026
a4cdd07
feats(demo) astubbs#242: the Go demo, and the broker moves out of the…
astubbs Aug 21, 2026
c125fe8
docs(inflight) astubbs#242: what the Go demo diverged on, and five th…
astubbs Aug 21, 2026
59eb10e
fix(rust) astubbs#242: a protoc Maven leaves unexecutable must not be…
astubbs Aug 21, 2026
d3e09a1
feats(demo) astubbs#242: the TypeScript demo, where the work has to b…
astubbs Aug 21, 2026
e21ab40
feats(demo) astubbs#242: the Rust demo, and what a blocking sleep cos…
astubbs Aug 21, 2026
13b0c2d
fix(demo) astubbs#242: run the sidecar on the classpath a user gets, …
astubbs Aug 21, 2026
c519285
fix(demo) astubbs#242: the container has no .git, and a smaller image…
astubbs Aug 21, 2026
c924574
fix(demo) astubbs#242: keep the demo out of the library's lint, becau…
astubbs Aug 21, 2026
b4d7ab0
docs(inflight) astubbs#242: what the Rust demo measured, and the two …
astubbs Aug 21, 2026
a4dfebd
docs(demo) astubbs#242: spell out that the library builds before the …
astubbs Aug 21, 2026
5892f1c
feats(demo) astubbs#242: the Scala demo, and the four extra arms it d…
astubbs Aug 21, 2026
06ec442
docs(inflight) astubbs#242: what the Scala demo proved, and the two f…
astubbs Aug 21, 2026
d8e09c7
docs(inflight) astubbs#242: a BlockedThreadAsserter sighting the Kotl…
astubbs Aug 21, 2026
27abd7a
feats(demo) astubbs#242: the .NET demo, and the wait C# was told it d…
astubbs Aug 21, 2026
b7101a0
feats(demo) astubbs#242: the C++ demo - librdkafka beside the client …
astubbs Aug 21, 2026
e722230
docs(inflight) astubbs#242: what the C++ demo could not mirror, and w…
astubbs Aug 21, 2026
99cd244
feats(demo) astubbs#242: Ruby's own Kafka client against Ruby over th…
astubbs Aug 21, 2026
2381ea3
feats(demo) astubbs#242: the Swift demo, and what Swift's concurrency…
astubbs Aug 21, 2026
a18ddc5
docs(inflight) astubbs#242: what the Ruby demo decided, and the two p…
astubbs Aug 21, 2026
f5cc475
fix(dotnet) astubbs#242: the module could not be built in an arm64 co…
astubbs Aug 21, 2026
8af0eb0
Merge branch 'demos/python' into feats/polyglot-demos
astubbs Aug 21, 2026
1da1339
Merge branch 'demos/typescript' into feats/polyglot-demos
astubbs Aug 21, 2026
68d3dbd
Merge branch 'demos/go' into feats/polyglot-demos
astubbs Aug 21, 2026
9e10869
Merge branch 'demos/rust' into feats/polyglot-demos
astubbs Aug 21, 2026
04bac60
Merge branch 'demos/kotlin' into feats/polyglot-demos
astubbs Aug 21, 2026
b650a0a
Merge branch 'demos/scala' into feats/polyglot-demos
astubbs Aug 21, 2026
0ec2d25
Merge branch 'demos/swift' into feats/polyglot-demos
astubbs Aug 21, 2026
1ddba0c
Merge branch 'demos/cpp' into feats/polyglot-demos
astubbs Aug 21, 2026
4b43432
Merge branch 'demos/dotnet' into feats/polyglot-demos
astubbs Aug 21, 2026
a8fc6cd
Merge branch 'demos/ruby' into feats/polyglot-demos
astubbs Aug 21, 2026
9d3ee93
feats(demo) astubbs#242: make the demos watchable, and fix the dial d…
astubbs Aug 21, 2026
5d077be
docs(clients) astubbs#242: name the two coverages, and which one a fe…
astubbs Aug 21, 2026
c2bcd00
docs(inflight) astubbs#242: capture the demo testing-infrastructure a…
astubbs Aug 21, 2026
2013311
feats(demo) astubbs#242: TypeScript demo opens with the product, and …
astubbs Aug 21, 2026
bf6fa33
feats(scala) astubbs#242: open by naming the product, name the client…
astubbs Aug 21, 2026
1c04d0a
docs(inflight) astubbs#242: the conformance skeleton goes quiet on th…
astubbs Aug 21, 2026
4422da2
feats(python) astubbs#242: open by naming the product, name the clien…
astubbs Aug 21, 2026
ad06442
feats(go) astubbs#242: the demo says what it is, which client ran, an…
astubbs Aug 21, 2026
c83cdca
docs(inflight) astubbs#242: the container path passes, and the broker…
astubbs Aug 21, 2026
7146a0f
feats(kotlin) astubbs#242: say what the demo is, what ran it, and wha…
astubbs Aug 21, 2026
6a2d5d8
feats(demo) astubbs#242: the Rust demo says what it is, and what each…
astubbs Aug 21, 2026
3ea8f07
docs(demo) astubbs#242: the sleep comments named the language, and th…
astubbs Aug 21, 2026
fa14cf1
docs(go) astubbs#242: the container path ran, and it says the broker …
astubbs Aug 21, 2026
02544ca
fix(demo) astubbs#242: the conformance harness was about to go green …
astubbs Aug 21, 2026
7f73d24
fix(demo) astubbs#242: the harness forbade what the contract invited
astubbs Aug 21, 2026
409113c
docs(inflight) astubbs#242: record what the polish wave will need rec…
astubbs Aug 21, 2026
4eee3ea
feats(demo) astubbs#242: make the .NET demo say what it is, what ran …
astubbs Aug 21, 2026
ff8a71a
feats(cpp) astubbs#242: make the C++ demo say what it is, what ran it…
astubbs Aug 21, 2026
2ebcd3f
feats(demo) astubbs#242: run every serious client, write every demo n…
astubbs Aug 21, 2026
3359856
docs(inflight) astubbs#242: two languages let a broken check define t…
astubbs Aug 21, 2026
fe61216
feats(demo) astubbs#242: say what the Java demo is, and let each row …
astubbs Aug 21, 2026
6439f47
docs(demo) astubbs#242: retry ports rather than reserving them, and t…
astubbs Aug 21, 2026
5cdd606
feats(ruby) astubbs#242: make the demo the owner watched say what it is
astubbs Aug 21, 2026
293cc9d
fix(demo) astubbs#242: repair the OTHER harness, which the same chang…
astubbs Aug 21, 2026
46bc288
feats(swift) astubbs#242: say what the demo is, name the client, and …
astubbs Aug 21, 2026
97892c5
feats(harness): warn before the disk fills, on every Bash call rather…
astubbs Aug 21, 2026
9e757b9
Merge branch 'polish/cpp' into feats/polyglot-demos
astubbs Aug 21, 2026
b94c5cb
Merge branch 'polish/dotnet' into feats/polyglot-demos
astubbs Aug 21, 2026
d0c1012
Merge branch 'polish/go' into feats/polyglot-demos
astubbs Aug 21, 2026
8760747
Merge branch 'polish/java' into feats/polyglot-demos
astubbs Aug 21, 2026
2897243
Merge branch 'polish/kotlin' into feats/polyglot-demos
astubbs Aug 21, 2026
f6a0f4c
Merge branch 'polish/python' into feats/polyglot-demos
astubbs Aug 21, 2026
d9cafc6
Merge branch 'polish/ruby' into feats/polyglot-demos
astubbs Aug 21, 2026
afb8948
Merge branch 'polish/rust' into feats/polyglot-demos
astubbs Aug 21, 2026
7fb8bb8
Merge branch 'polish/scala' into feats/polyglot-demos
astubbs Aug 21, 2026
a2c94a0
Merge branch 'polish/swift' into feats/polyglot-demos
astubbs Aug 21, 2026
c9308a4
Merge branch 'polish/typescript' into feats/polyglot-demos
astubbs Aug 21, 2026
a7b0389
feats(demos) astubbs#242: one column order across eleven languages, a…
astubbs Aug 21, 2026
e47e48e
fix(demos) astubbs#242: the big replay no longer claims the serial ar…
astubbs Aug 21, 2026
9711d7f
docs(inflight) astubbs#242: the container demo never exits, and the b…
astubbs Aug 21, 2026
16a6076
docs(inflight) astubbs#242: soak lanes per language, and a correction…
astubbs Aug 21, 2026
e7f5464
docs(inflight) astubbs#242: the demo hang is a core defect - close() …
astubbs Aug 21, 2026
0001f78
test(reactor) astubbs#242: record the concurrency sighting, and unbre…
astubbs Aug 21, 2026
481b679
docs(agents) astubbs#242: name the three toolchain gaps that read as …
astubbs Aug 21, 2026
f3bbb21
docs(agents) astubbs#242: correct the toolchain requirement - it is m…
astubbs Aug 21, 2026
ddd74c1
docs(inflight) astubbs#242: container-built runners cannot execute on…
astubbs Aug 21, 2026
e2762c3
feats(demos) astubbs#242: name the product in every arm - pc-<lang>-g…
astubbs Aug 21, 2026
7c0cdf0
feats(build) astubbs#242: assert the toolchain at build time, and dec…
astubbs Aug 21, 2026
2be83a2
feats(conformance) astubbs#242: run cpp and swift conformance inside …
astubbs Aug 21, 2026
c354d8b
Merge branch 'feats/java-sidecar-demo' into feats/polyglot-demos
astubbs Aug 21, 2026
b725a23
docs(inflight) astubbs#242: the uber demo is idea 21, and the expensi…
astubbs Aug 22, 2026
2141a5b
docs(inflight) astubbs#242: correct the uber-demo design - the gap is…
astubbs Aug 22, 2026
d8d7dab
Merge feats/java-sidecar-demo into feats/polyglot-demos
astubbs Aug 24, 2026
c346ba4
Merge branch 'feats/polyglot-demos' into feats/uber-demo-all-languages
astubbs Aug 24, 2026
8bb89e5
Merge feats/uber-demo-all-languages into feats/polyglot-demos, collap…
astubbs Aug 24, 2026
0d6541a
merge: the demo rung into the per-language demos, and let the glob ab…
astubbs Aug 31, 2026
268bd76
merge: the demo rung into the per-language demos, carrying the eviden…
astubbs Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ is untracked (a whole triage doc was once written duplicating `docs/refactoring.
| [`docs/self-hosted-runner.md`](docs/self-hosted-runner.md) | Setting up or operating the self-hosted highcpu runner |
| [`docs/agent-harness.md`](docs/agent-harness.md) | Adding a rule you need agents to follow *reliably* - which layers fire on their own, and which are merely available |
| [`docs/merge-checklist.md`](docs/merge-checklist.md) | Getting a PR ready to merge - what to offer the author, including the squash message and reorganising the commits |
| [`bin/AGENTS.md`](bin/AGENTS.md) | Writing or changing a script in `bin/` - the shell conventions, including the ones no check enforces |
| [`bin/AGENTS.md`](bin/AGENTS.md) | Writing or changing **any shell script** - `bin/`, `.githooks/`, `.claude/hooks/` - the shell conventions, including the cross-platform rule and the others no check enforces |
| [`docs/inflight/AGENTS.md`](docs/inflight/AGENTS.md) | Adding, editing or retiring a note in `docs/inflight/` - what may live there, the tag vocabulary, and where a note's content goes when its work lands |
| [`parallel-consumer-proxy-clients/AGENTS.md`](parallel-consumer-proxy-clients/AGENTS.md) | Changing a client or its demo - the two test suites over them, which one your feature needs, and why neither substitutes for the other |
| [`parallel-consumer-core/src/main/java/bz/stub/parallelconsumer/AGENTS.md`](parallel-consumer-core/src/main/java/bz/stub/parallelconsumer/AGENTS.md) | Changing a field in the engine - the `@GuardedBy` rule, the known shared state and its ledgers, and the shard-map pin |

**A directory with its own `AGENTS.md` owns the rules for what goes in it - read it before you write
Expand Down Expand Up @@ -292,9 +293,28 @@ counts. This is a community-maintained fork of the no-longer-maintained

## Build Requirements

- **JDK 17** (the project uses Jabel to compile Java 17 source to Java 8 bytecode)
- **JDK 17** (the project uses Jabel to compile Java 17 source to Java 8 bytecode). On JDK 21 the
build dies as `Unable to delombok: InvocationTargetException` in `parallel-consumer-core` - a
module your change never touched, so it reads as a real breakage and is not one. Check
`java -version` first. Set it **per command** (`JAVA_HOME=... ./mvnw`), never by repointing a
shared default that every other session inherits.
- **Docker** (integration tests - TestContainers spins up Kafka brokers)
- **Maven via wrapper** (`./mvnw`) - do not use system Maven
- **[mise](https://mise.jdx.dev)**, for the cross-language conformance module and the client
builds. `mise install` reads **`mise.toml`**, which is the single declaration of every toolchain
version; the CI matrix mirrors it and `bin/check-toolchain-versions.sh` fails if the two ever
disagree. It provides every foreign toolchain except the two that build in containers
(`bin/build-client.sh` names them: C++ and Swift). **Agents may install it and run `mise use -g`
themselves** - that call is recorded in
[`docs/inflight/parked-containerised-toolchains-and-runtime.md`](docs/inflight/parked-containerised-toolchains-and-runtime.md),
along with the rule that follows from it: *do not build an image fleet for problems mise has
already solved*.

Without mise the build falls through to whatever the host has, and the conformance module then
**fails** - correctly, because it refuses to let a runner nobody could build look like a runner
that passed. The two failure modes read as project breakage and are not: Go reports
`go.mod: unknown block type: tool` when `go env GOTOOLCHAIN` is `local` rather than its default
`auto`, and Ruby reports a missing `bundler` when macOS's system 2.6 is on `PATH`.

## How to Build

Expand Down
46 changes: 44 additions & 2 deletions bin/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# `bin/` - how these scripts relate to CI and to the reviewer

Repo scripts. This doc owns the conventions for writing a script in `bin/`; the root AGENTS.md
routes here and keeps only what binds every session. Two conventions live here because nothing else
enforces them.
routes here and keeps only what binds every session. These conventions live here because nothing
else enforces them. The portability rule below is the exception to the directory in the title: it
binds **every shell script in the repo**, including `.githooks/` and `.claude/hooks/`.

## Naming a script here can grant it to the PR reviewer

Expand Down Expand Up @@ -61,6 +62,33 @@ Two structural guards exist and are worth copying into any new checker's self-te
buried mid-body and a match at the very end, and neither can trigger the bug - the first is small,
the second has nothing following it. A case that reaches it is added in astubbs#210.

## Every script must be cross-platform, and degrade rather than guess

Scripts here run on macOS dev machines, Linux CI runners, and self-hosted runners, so **a script that
only works on the author's machine is a broken script**. Two rules, and the second is the one that
actually bites:

- **Never let a missing tool become a confident wrong answer.** Probe for what you need and skip what
is absent, saying so. `docker`, `gdate`, `realpath`, `timeout` and `sha256sum` are all absent
somewhere this repo runs. A check that cannot run must report that it did not run - the failure
mode to design against is the one where "I could not measure it" is indistinguishable from "it is
fine".

- **`stat -f` is NOT a portable idiom, and must never be used as a try-then-fallback.** On macOS
`stat -f %b` is a *file's* allocated blocks; on GNU/Linux `-f` switches stat into *filesystem*
mode, where `%b` is the total blocks in the filesystem. It exits **0** and returns a number three
orders of magnitude too large, so `stat -f ... || stat -c ...` silently produces garbage on Linux
rather than falling through. Resolve the platform once from `uname -s` and branch. The same
divergence applies to `date -r` vs `date -d`, `sed -i ''` vs `sed -i`, and `readlink -f`.

`.claude/hooks/warn-low-disk.sh` is the worked example, and it also shows the seam that makes such
a branch testable: which `stat` **syntax** to speak is read from the real `uname` and is never
injectable, while which platform **layout** to look for is injectable - so a self-test can exercise
the macOS branch on a Linux runner without pretending Linux has BSD stat.

- **`df` needs `-P`.** Without POSIX output a long device name wraps onto its own line on Linux and a
column-indexing `awk` silently reads the wrong field.

## Workflows

**One version per action.** `bin/check-action-versions.sh` fails if any action appears at two
Expand All @@ -78,6 +106,20 @@ caught it in review, and the same mistake had been seen before.
The github-actions ecosystem was re-enabled in `.github/dependabot.yml` at the same time; it had
been off since 2022, when the repo genuinely had no workflows.

**One toolchain declaration.** `bin/check-toolchain-versions.sh` fails when `mise.toml` and the
`.github/workflows/clients.yml` matrix disagree about a language's version, and runs in Repo Hygiene
behind its own self-test. The two *install* differently on purpose - mise locally, `setup-*` actions
on the runner, which carry the client matrix's caching and a SHA-pinned `ruby/setup-ruby` - but they
must never *declare* differently. Same class as the rule above: **nothing else could catch it**, and
they had already drifted by whole major versions (dotnet 8 against 9, node 22 against 25) before the
gate existed. The four languages with no host toolchain (swift and cpp build in containers, kotlin
and scala on the Maven reactor) are asserted absent from both, so "nobody declared it" cannot be
mistaken for "it deliberately has none".

`bin/build-client.sh` asserts the *installed* version separately, at build time, on major.minor
only - the declarations are exact, but a developer one patch along from the pin is not a problem
worth blocking, and every failure the assertion exists for was a major or minor gap.

**Temporary CVE exclusions expire.** `bin/check-cve-exclusions.sh` parses the root pom's
`excludeVulnerabilityIds` and fails once an entry marked `TEMPORARY-SINCE: YYYY-MM-DD` is more than
90 days old - also on an undated, unparseable or future-dated marker, and on an id with no rationale
Expand Down
87 changes: 87 additions & 0 deletions bin/build-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
readonly REPO_ROOT
readonly CLIENTS_DIR="parallel-consumer-proxy-clients"
readonly MODULE_PREFIX="parallel-consumer-proxy-client-"
readonly PROTO_CONTEXT="parallel-consumer-proxy-protocol/src/main/proto"
Expand Down Expand Up @@ -170,12 +172,97 @@ maven_module_list() { # <module dir> -> comma-separated :artifactId list
printf '%s' "$list"
}

# ─────────────────────────────────────────────────────────────────────────────────────────────────
# TOOLCHAIN VERSION ASSERTION
#
# Maven shells out to whatever `go`, `ruby`, `node`, `python3`, `rustc` or `dotnet` is on PATH, and
# a wrong one does not announce itself - it produces an error deep inside a module nobody touched.
# Both real cases cost an hour each before anyone suspected their PATH: Go 1.23 against a 1.25
# module reports `go.mod: unknown block type: tool`, and macOS's system Ruby 2.6 reports a missing
# `bundler`. Neither says "your toolchain is the wrong version", which is the only useful sentence.
#
# MAJOR.MINOR, NOT EXACT. mise.toml and CI pin an exact patch and `bin/check-toolchain-versions.sh`
# holds those two declarations identical - but asserting the patch HERE would fail every developer
# whose mise resolved one release along (this was written on a box with go 1.25.14 against a pinned
# 1.25.13) while catching nothing: every failure this guard exists for was a major or minor gap.
#
# A version it cannot read is a FAILURE, not a pass. "I could not check" and "it is fine" must never
# look the same - the rule bin/AGENTS.md states for every script here.
# ─────────────────────────────────────────────────────────────────────────────────────────────────

# The runtime each language builds with. `typescript` is the one place the language and the tool have
# different names, matching the mapping in bin/check-toolchain-versions.sh.
tool_for_language() { # <language> -> tool name, or empty when the language has no host toolchain
case "$1" in
typescript) printf 'node' ;;
go | ruby | rust | python | dotnet) printf '%s' "$1" ;;
*) printf '' ;;
esac
}

declared_version() { # <tool> -> the version mise.toml pins, or empty
local mise="$REPO_ROOT/mise.toml"
[ -f "$mise" ] || return 0
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$mise" | head -1
}

active_version() { # <tool> -> the version actually on PATH, or empty when it cannot be determined
case "$1" in
go) go version 2>/dev/null | sed -n 's/.*go\([0-9][0-9.]*\).*/\1/p' ;;
ruby) ruby --version 2>/dev/null | sed -n 's/^ruby \([0-9][0-9.]*\).*/\1/p' ;;
rust) rustc --version 2>/dev/null | sed -n 's/^rustc \([0-9][0-9.]*\).*/\1/p' ;;
python) python3 --version 2>/dev/null | sed -n 's/^Python \([0-9][0-9.]*\).*/\1/p' ;;
node) node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9.]*\).*/\1/p' ;;
dotnet) dotnet --version 2>/dev/null | sed -n 's/^\([0-9][0-9.]*\).*/\1/p' ;;
*) printf '' ;;
esac
}

major_minor() { # <version> -> first two components
printf '%s' "$1" | cut -d. -f1,2
}

assert_toolchain_version() { # <language>
local language="$1" tool declared active
tool="$(tool_for_language "$language")"
[ -n "$tool" ] || return 0

declared="$(declared_version "$tool")"
if [ -z "$declared" ]; then
die 1 "$language: mise.toml pins no version for $tool, so the toolchain cannot be checked.
Add it, or remove $language from tool_for_language() and say why it has no host toolchain."
fi

active="$(active_version "$tool")"
if [ -z "$active" ]; then
die 1 "$language: could not determine the installed $tool version - is it on PATH?
mise.toml pins $tool $declared; install it with 'mise install' (or 'mise use -g $tool@$declared').
Refusing to build rather than guess: an unreadable toolchain is not a working one."
fi

if [ "$(major_minor "$active")" != "$(major_minor "$declared")" ]; then
die 1 "$language: wrong $tool version.
on PATH : $active
mise.toml : $declared
Install the pinned one with 'mise install', or 'mise use -g $tool@$declared'.
Building anyway produces an error inside a module you did not touch - Go reports
'unknown block type: tool', Ruby reports a missing bundler - which reads as project breakage."
fi

if [ "$active" != "$declared" ]; then
printf '==> %s: %s %s on PATH, %s pinned - same minor, continuing\n' \
"$language" "$tool" "$active" "$declared"
fi
}

build_natively() { # <maven phase>
local phase="$1"
local module_dir="$CLIENTS_DIR/$MODULE_PREFIX$LANGUAGE"
local projects
projects="$(maven_module_list "$module_dir")"

assert_toolchain_version "$LANGUAGE"

printf '==> %s: native toolchain via Maven (%s), projects %s\n' "$LANGUAGE" "$phase" "$projects"
# No `clean`, deliberately: several agents and worktrees share this tree, and a clean here would
# delete output nobody asked this script to rebuild.
Expand Down
124 changes: 124 additions & 0 deletions bin/check-toolchain-versions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# Copyright (C) 2026 Antony Stubbs and contributors
#

# Asserts that `mise.toml` and the `.github/workflows/clients.yml` matrix declare the SAME toolchain
# versions.
#
# WHY IT EXISTS. The two install differently on purpose - mise locally, `setup-*` actions in CI,
# because those actions carry the client matrix's caching and `ruby/setup-ruby` is SHA-pinned. What
# is not on purpose is that they were two sources of truth for the same numbers with nothing
# comparing them, and they had already drifted by WHOLE MAJOR VERSIONS before anyone looked: dotnet
# 8.0.404 in CI against 9.0.101 on a developer machine, node 22.17.0 against 25.9.0. That is the
# classic shape of "passes locally, fails in CI" - and the nastier reverse, where CI runs the older
# runtime and the developer never sees the failure their change causes.
#
# WHAT IT DOES NOT DO. It does not check what is INSTALLED - that is
# `assert_toolchain_version` in bin/build-client.sh, which runs at build time against the machine.
# This one compares two declarations, so it is exact: a patch difference between two files that are
# both meant to say the same thing is a mistake, not tolerable drift.
#
# THE FOUR ABSENT LANGUAGES ARE CHECKED TOO, because "nobody declared it" and "it deliberately has no
# host toolchain" look identical otherwise: swift and cpp build in a container, kotlin and scala on
# the Maven reactor, so all four must be absent from mise.toml AND carry an empty matrix toolchain.
#
# Read-only: no network, no writes, nothing outside this repository. Safe under the `check-*` grant
# described in bin/AGENTS.md.
#
# Run: bin/check-toolchain-versions.sh

set -uo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# The two overrides exist only for bin/test-check-toolchain-versions.sh, which has to drive this
# gate against crafted fixtures to prove it can go red. bin/AGENTS.md's rule is that a regression
# test which has never failed proves nothing, and the only other way to make this one fail is to
# break the real repository.
MISE="${PC_MISE_TOML:-$REPO_ROOT/mise.toml}"
WORKFLOW="${PC_CLIENTS_WORKFLOW:-$REPO_ROOT/.github/workflows/clients.yml}"

for f in "$MISE" "$WORKFLOW"; do
[ -f "$f" ] || {
printf 'check-toolchain-versions: MISSING %s - cannot compare, so this is a FAILURE rather than a pass\n' "$f" >&2
exit 1
}
done

# python3 rather than a yaml/toml parser: it is already a hard dependency of the agent hooks and the
# conformance harness, and adding a parser dependency to a gate that guards the build is backwards.
python3 - "$MISE" "$WORKFLOW" <<'PY'
import re
import sys

mise_path, workflow_path = sys.argv[1], sys.argv[2]

# The matrix names the LANGUAGE; mise names the RUNTIME. They differ in exactly one place, and
# hard-coding that one mapping is clearer than inferring it.
LANGUAGE_TO_TOOL = {"typescript": "node"}

# Languages with no host toolchain by design. Named here so that a language quietly losing its
# version is a failure rather than joining this list by accident.
NO_HOST_TOOLCHAIN = {"swift", "cpp", "kotlin", "scala"}

mise_text = open(mise_path).read()
tools_block = re.search(r"^\[tools\]\s*$(.*?)(?=^\[|\Z)", mise_text, re.M | re.S)
if not tools_block:
print("check-toolchain-versions: mise.toml has no [tools] section", file=sys.stderr)
raise SystemExit(1)
declared = dict(re.findall(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]+)"', tools_block.group(1), re.M))

workflow_text = open(workflow_path).read()
entries = re.findall(r"- language:\s*(\S+)(.*?)(?=\n\s*- language:|\Z)", workflow_text, re.S)
matrix = {}
for language, body in entries:
found = re.search(r"toolchain:\s*'([^']*)'", body)
if found is not None:
matrix[language] = found.group(1)

if not matrix:
print("check-toolchain-versions: parsed NO languages out of the workflow matrix - the format "
"changed, and a gate that silently checks nothing is worse than no gate", file=sys.stderr)
raise SystemExit(1)

problems = []

for language, ci_version in sorted(matrix.items()):
tool = LANGUAGE_TO_TOOL.get(language, language)
if language in NO_HOST_TOOLCHAIN:
if ci_version:
problems.append(f"{language}: builds without a host toolchain, but the CI matrix pins "
f"'{ci_version}'. Either it now needs one (add it to mise.toml) or the "
f"pin is stale.")
if tool in declared:
problems.append(f"{language}: builds without a host toolchain, but mise.toml declares "
f"{tool} = \"{declared[tool]}\".")
continue
if not ci_version:
problems.append(f"{language}: the CI matrix declares no toolchain version. If that is "
f"deliberate, add it to NO_HOST_TOOLCHAIN in this script and say why.")
continue
if tool not in declared:
problems.append(f"{language}: CI pins '{ci_version}' but mise.toml declares no {tool}.")
continue
if declared[tool] != ci_version:
problems.append(f"{language}: mise.toml says {tool} = \"{declared[tool]}\", the CI matrix "
f"says '{ci_version}'. These must be identical.")

matrix_tools = {LANGUAGE_TO_TOOL.get(lang, lang) for lang in matrix}
for tool in sorted(set(declared) - matrix_tools):
problems.append(f"mise.toml declares {tool} = \"{declared[tool]}\", which no language in the CI "
f"matrix uses. Remove it, or add the language that needs it.")

if problems:
print("check-toolchain-versions: the two declarations disagree.\n", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
print("\nmise.toml is the single declaration; the workflow matrix must mirror it. They install\n"
"differently on purpose - see the header of mise.toml.", file=sys.stderr)
raise SystemExit(1)

checked = len(matrix)
print(f"check-toolchain-versions: {checked} language(s) agree between mise.toml and the CI matrix "
f"({len(declared)} pinned, {len(NO_HOST_TOOLCHAIN & set(matrix))} deliberately without a host toolchain).")
PY
Loading
Loading