Skip to content

Set matching CC alongside CXX in Linux CI matrix - #4863

Merged
vitaut merged 2 commits into
fmtlib:mainfrom
hexonal:fix-linux-ci-cc-cxx-mismatch
Jul 30, 2026
Merged

Set matching CC alongside CXX in Linux CI matrix#4863
vitaut merged 2 commits into
fmtlib:mainfrom
hexonal:fix-linux-ci-cc-cxx-mismatch

Conversation

@hexonal

@hexonal hexonal commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #4858

Problem

The Linux CI matrix deliberately varies matrix.cxx across old and new compilers (g++-4.9, clang++-3.6, clang++-20, etc.) to test compatibility, but the Configure step only sets CXX. With no CC, CMake's C compiler detection falls back to whatever default happens to be on the runner (as reported: clang++-3.6 for C++, but GNU 11.4.0 detected for C) — completely unrelated to the compiler version the job is actually meant to be testing.

Fix

Add a small step before Configure that derives CC from the same matrix.cxx value each job already installs a matching compiler for (clang++-Nclang-N, g++-Ngcc-N):

cc=${{matrix.cxx}}
cc=${cc/clang++/clang}
cc=${cc/g++/gcc}
echo "CC=$cc" >> "$GITHUB_ENV"

Testing

This is a CI-config-only change, so the real test is the workflow run itself — I checked every distinct matrix.cxx value used in this file (g++-4.9, g++-11, g++-13, g++-14, clang++-3.6, clang++-11, clang++-14, clang++-20) against the corresponding install/package steps in the same file to confirm a matching gcc-N/clang-N binary is actually installed for each before relying on it (e.g. the "Install GCC 4.9" step explicitly dpkg -is a gcc-4.9 package, "Install Clang 3.6" explicitly installs clang-3.6, etc.) — the two entries without an explicit install step (g++-13/g++-14 on ubuntu-24.04, one clang++-14 variant) rely on the runner image's default toolchain, where the C and C++ drivers for the same version are always installed together. I also verified the string-substitution logic locally in bash against all 8 values (clang++-3.6 correctly does not get double-substituted by the g++gcc replacement, since the clang++clang substitution runs first and already consumes the trailing g++ substring), and validated the YAML with ruby -ryaml. I can't run the actual matrix locally, so I'll be watching this PR's own CI run closely.

@hexonal
hexonal requested a review from vitaut as a code owner July 21, 2026 01:17
@hexonal

hexonal commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

The clang++-3.6/g++-4.9 jobs failed on this run, but not because of this PR's change. The actual error in the clang++-3.6 jobs is wget failing to download the old compiler .debs from launchpadlibrarian.net with repeated ERROR 503: Service Unavailable — an external package-mirror outage, unrelated to the CC/CXX selection logic this PR touches (the new "Select matching C compiler" step itself ran fine, correctly resolving CC=gcc-4.9/CC=clang-3.6). The g++-4.9 jobs show as cancelled rather than failed, which is the matrix's default fail-fast behavior kicking in once the clang++-3.6 jobs errored.

I don't have permission to re-run just the failed jobs (not a repo admin) — happy to push an empty commit to retrigger if useful, or this should resolve on its own next time CI runs against this branch.

@vitaut

vitaut commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

I triggered a re-run.

@hexonal

hexonal commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-run -- that surfaced something worth flagging.

Good news: the compiler downloads succeeded this time (no more wget 503s), and the new "Select matching C compiler" step resolved CC correctly -- CC=clang-3.6 for the clang job, confirmed by CMake logging "The C compiler identification is Clang 3.6.2". So the actual fix in this PR is working as intended.

But clang++-3.6 c++11 Release now fails for a different, genuine reason: test/c-test.c fails to compile with "too many arguments to function call, expected 0, have 1" on the "foo" string-literal arguments (lines 68 and 93). I traced it to FMT_MAKE_ARG in include/fmt/fmt-c.h, which dispatches on argument type via C11 _Generic and falls back to a 0-arg fmt_unsupported_type() for unmatched types. Clang 3.6's _Generic appears not to apply array-to-pointer decay to the controlling expression, so a "foo" argument (type char[4]) doesn't match the char*/const char* associations and falls into that 0-arg default, which then gets called with (x) anyway -- hence the error.

Importantly, this isn't something this PR's diff introduces (it only touches the workflow file), and it isn't new in the code -- it's a preexisting incompatibility between the C API and real Clang 3.6 that CI never actually caught before, for exactly the reason this PR exists: CC was silently defaulting to the runner's GCC 11, so the C side of the "clang++-3.6" job was never really compiled with clang-3.6. I confirmed this by comparing against today's main run of the same job (identical fmt-c.h/c-test.c -- I diffed them): on main, C still identifies as "GNU 11.4.0" there, and c-test.c compiles and its ctest passes fine under that substituted GCC. So this fix is working correctly; it just uncovered a real, previously-hidden bug.

g++-4.9 got cancelled by fail-fast before reaching this step, so it's still unknown whether it hits the same or a different issue there.

Since fixing fmt-c.h for Clang 3.6 is outside what this PR is meant to do, how would you like to handle it -- a follow-up issue, or would you rather I look at a fix for the _Generic fallback here? Happy to do either.

@vitaut

vitaut commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

how would you like to handle it -- a follow-up issue, or would you rather I look at a fix for the _Generic fallback here?

If there is an easy fix, then I suggest doing it as part of the current PR. Otherwise, we could disable the C test in the problematic config for now.

Thanks for looking into it!

@hexonal

hexonal commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Dug into this -- root cause is confirmed and it's not a bug in fmt's macro. FMT_MAKE_ARG's _Generic dispatch in fmt-c.h relies on the controlling expression decaying char[N] string literals to char*/const char* before type matching. Whether _Generic's controlling expression decays arrays was genuinely ambiguous in the C11 wording (WG14 N1930) -- GCC always applied the decay, Clang didn't, until the fix for LLVM PR16340 landed in Nov 2015 (clang 3.8). Clang 3.6.2 (Feb 2015) predates that fix by a year, so "foo" never matches char*/const char* and falls through to the 0-arg default, which is then called with an argument -- exactly the reported error.

I looked hard for a macro-level fix and don't think there's a safe one for this PR. The obvious workaround -- forcing decay by wrapping the argument as (x)+0 or 1 ? (x) : (x) -- does force the array decay, but it also triggers ordinary C integer promotion on every small-integer case in the table (char, bool, unsigned char, short, ...), on every compiler, not just old Clang. I verified this concretely: it reroutes a plain char argument to the int case instead of char, which would silently change how the C API formats char/bool/short arguments everywhere, not just paper over Clang 3.6. There's no way to scope the workaround to "just the array case" without already knowing the argument's type at macro-expansion time, which is the very thing _Generic exists to determine, so this really is a compiler bug rather than something fixable in fmt-c.h without a bigger redesign.

Given that, I went with your second option: skipped just the c-test target (via ctest -E ^c-test$) for the clang++-3.6 job, with a comment explaining the LLVM PR16340 history. That job's own C++ tests are untouched and still run -- only the C-API smoke test is skipped, and only for that one legacy compiler.

One thing worth flagging on my own work: my first pass at this had a real bug -- the templated exclude=${{ ... }} value needs to be double-quoted when assigned, since GitHub Actions substitutes it inline and the unquoted form (exclude=-E ^c-test$) doesn't parse as a single shell assignment under bash's default -e mode, it tries to run a nonexistent ^c-test$ command and aborts the step. Caught and fixed that before pushing, and verified both the clang++-3.6 branch and every other matrix entry by actually executing the resulting shell snippet, not just reading it.

Pushed.

@vitaut vitaut left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lint warning has been fixed in main (you need to rebase) but it looks that the test-only opt out was insufficient.

hexonal added 2 commits July 30, 2026 04:13
The Configure step only set CXX, so CMake's C compiler detection
fell back to whatever the default happened to be on the runner,
independent of which C++ compiler the matrix entry was actually
testing (e.g. CXX=clang++-3.6 but CC left to detect GCC 11).

Derive CC from the same matrix.cxx value the job already installs
a matching compiler for.
Setting CC from matrix.cxx means the clang++-3.6 job now configures with real
clang 3.6 instead of falling back to the runner's default GCC, which exposes a
compile failure in test/c-test.c: clang predates the LLVM PR16340 fix (landed in
3.8) that applies array-to-pointer decay to the controlling expression of
_Generic, so fmt-c.h's FMT_MAKE_ARG dispatch never matches a string literal
(char[4]) against its char*/const char* associations. It falls through to the
zero-argument default association, which is then called with one argument.

The guard is at configure time rather than in the CI Test step. c-test is part
of the default target, so `cmake --build` compiles it before ctest ever runs --
excluding it with `ctest -E` cannot help, because the job has already failed in
the Build step. Guarding the add_executable also means anyone building fmt's
tests with an old clang benefits, not just this one CI job.

enable_language(C) stays first and unconditional, since CMAKE_C_COMPILER_VERSION
is not set before it. fmt-c itself (src/fmt-c.cc, C++) still builds on that job;
only the C-consumer smoke test is skipped.
@hexonal
hexonal force-pushed the fix-linux-ci-cc-cxx-mismatch branch from 841a3c3 to 5ae4721 Compare July 30, 2026 08:17
@hexonal

hexonal commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed — rebased onto current main (picked up c851fbe, so cmake-format is green again), and the opt-out moved to where it can actually take effect.

Why the previous one couldn't work. c-test is an unconditional add_executable, so it's part of the default target and gets compiled by cmake --build — the job died in the Build step and the Test step never ran at all, so ctest -E was dead code. I confirmed that from the jobs API rather than inferring it from the log: on run 89122874992, step 11 Build = failure, step 12 Test = skipped. ctest -E filters test registration; it has no bearing on compilation.

So the guard is now at configure time, in test/CMakeLists.txt:

enable_language(C)
# Clang < 3.8 doesn't apply array-to-pointer decay to the controlling
# expression of _Generic (https://llvm.org/PR16340), so fmt-c.h's argument
# dispatch doesn't match string literals there.
if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND CMAKE_C_COMPILER_VERSION
                                             VERSION_LESS 3.8)
  message(STATUS "Skipping c-test: _Generic is broken in Clang < 3.8")
else ()
  ...
endif ()

enable_language(C) stays first and unconditional, since CMAKE_C_COMPILER_VERSION isn't set before it. STREQUAL rather than MATCHES so AppleClang (unrelated version numbering) can't trip it. fmt-c itself is C++ (src/fmt-c.cc) and still builds on that job — only the C-consumer smoke test is skipped. Side benefit over the CI-only version: anyone building fmt's tests with an old clang gets the same treatment.

The Test step is back to exactly main's four lines, so the workflow diff is now just the 7-line Select matching C compiler step.

Verified locally (cmake 4.4.0, AppleClang 21):

  • Guard doesn't fire on a modern clang — c-test builds, one c-test entry in ctest -N.
  • Forcing it to fire — the STATUS line prints, --target c-test reports no such target, ctest -N has zero c-test entries, and a full cmake --build succeeds. That last one is the part that matters, since it's what the Build step does.
  • Ran the lint gate the way CI does (find . -name CMakeLists.txt -o -name '*.cmake' | xargs cmake-format -i && git diff --exit-code, pinned cmakelang 0.6.13) on a clean tree: no diff.

One correction to something I said earlier. I claimed there was no way to scope a macro-level workaround to just the array case. That was too strong. (x)+0 and 1 ? (x) : (x) do promote char/short as I said, but the comma operator _Generic((0, (x)), ...) forces array decay while leaving char, signed char, unsigned char, short and bool unpromoted — it applies lvalue conversion without integer promotion. I only have Apple clang here, so whether clang 3.6 decays in that position is unverified, which is why I'm not proposing it as the fix. Happy to open a separate issue if it's worth exploring.

One thing to watch on this run. With CC now set, the g++-4.9 jobs will compile c-test.c for the first time — they've only ever been fail-fast casualties of the clang++-3.6 failure before. I expect it to pass (GCC accepts _Generic as an extension, and c-test carries no -pedantic/-Werror), but if it reddens, the right fix is set_property(TARGET c-test PROPERTY C_STANDARD 11) rather than widening the version guard — gcc-4.9 defaults to -std=gnu89 and nothing currently pins a C standard for that target.

If you'd rather feature-test than version-gate, a check_c_source_compiles probe on the _Generic pattern would work too — say the word and I'll switch it.

@hexonal

hexonal commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

The one red check that just appeared (g++-4.9 c++11 Release) is a runner network problem, not this change — it never got as far as any of the steps this PR touches.

Step 3 Install GCC 4.9 failed fetching the .debs from launchpadlibrarian.net with repeated Network is unreachable / Connection timed out (not 503, so the step's own --retry-on-http-error=503 didn't help), retried for 4h10m, then exited 4. Steps 8-11 — Select matching C compiler, Configure, Build, Test — are all skipped.

Same commit, g++-4.9 c++11 Debug: Install GCC 4.9 success, Select matching C compiler success, Test success, whole job in 3m39s. So the install works on this commit; that one runner just couldn't reach launchpad.

That leaves 45/46 green, with clang++-3.6 Debug + Release, cmake-format, and g++-4.9 c++11 Debug all passing — the Debug leg being the one that actually compiled c-test.c under real gcc 4.9 for the first time, which was the open question I flagged. A re-run of that single job whenever convenient should clear it.

@vitaut

vitaut commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

g++-4.9 is now green too after a re-run.

@vitaut
vitaut merged commit e2243a6 into fmtlib:main Jul 30, 2026
63 of 64 checks passed
@vitaut

vitaut commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Merged, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The Linux CI jobs use a different C compile than for C++

2 participants