fix(build): name artifacts from the target — cross builds relinked every time (B3) - #342
Merged
Merged
Conversation
… every build
Writes up B3 properly and corrects what the original §6.5 claimed.
The original said it was 'symmetrically wrong' — Windows→Linux produces
mcpp.exe for an ELF, Linux→Windows produces mcpp for a PE. The second half is
false. Measured: a Linux host cross-compiling to x86_64-windows-gnu produces
b3probe.exe (PE32+), because mingw's GCC driver appends .exe itself when the
-o name has no extension. mcpp never participates in that decision.
The actual defect is elsewhere and matters more. ninja is told the output is
bin/foo while GCC writes bin/foo.exe, so the declared file never exists and
ninja reruns the link edge on every build. Verified by mtime across two
consecutive builds — the artifact is relinked every time. Incremental builds
are effectively off for PE targets, which is the path CI exercises daily.
That also explains why nothing caught it: 102_mingw_cross_wine.sh looks for the
real artifact (find -name '*.exe'), not for what ninja declared, so both of its
assertions hold while the inconsistency sits underneath them.
Two further findings the fix has to account for:
- naming is an (os, env) function, not an os one. windows-gnu uses the GNU
convention (libfoo.a); only windows-msvc is foo.lib. The current _WIN32
branch hardcodes the latter, so building a static library with mingw ON a
Windows host is already misnamed today — a pre-existing defect unrelated to
cross-compilation.
- the blast radius is much smaller than §6.5 feared. e2e and CI need
essentially no changes, precisely because they match the real artifact.
Also confirms the other 15 exe_suffix references are correct host semantics
(locating ninja / xlings / clang++ on the build machine) and must not be
touched; the change is confined to src/build/plan.cppm.
Q1 — windows-gnu static lib foo.lib -> libfoo.a: DO IT IN THE SAME PR.
The worry was that it changes a host build's output name. Tracing every
consumer shows the blast radius is empty:
- mcpp package deps link at OBJECT level (plan.cppm:836 splices dependency
.o files into lu.objects). A static library is never produced OR read as
part of an internal dependency edge.
- external prebuilt libs come through free-form ldflags; the name is written
in the package descriptor, mcpp never spells it.
- [runtime] library_dirs is a directory, not a name; the Windows side only
filters on the .dll extension for runtime deployment.
- fingerprint.cppm carries no artifact name but does carry MCPP_VERSION, so
any bump already rotates target/<triple>/<fp>/. No mixed state, no cache
migration, no "cache clean" advice needed.
So the only consumer is whoever takes the artifact outside mcpp — and the name
they get today is wrong: mingw's ar emits a GNU archive named foo.lib, claiming
an MSVC convention it does not satisfy. That is a pre-existing correctness bug,
not a nice-to-have, and shipping the (os, env) rule half-way would leave a state
harder to explain than the bug.
Q2 — PE import libs: DO NOT MODEL THEM YET. Draw the boundary instead.
All five shared-library e2e tests declare "# requires: elf", and that capability
is only added on the Linux branch of run_all.sh — Darwin gets "macos", Windows
gets "windows". Shared libraries have therefore never been verified end to end
on PE *or* Mach-O. This reframes the question: it is not a missing feature, it
is a path that was never walked while the code carries branches that look like
it was.
Those branches are speculation: mingw tolerates linking a .dll directly, MSVC's
link.exe cannot — and the branch keys on the host constant, so it points the
wrong way under cross-compilation anyway.
Recommends rejecting SharedLibrary on non-ELF targets with a clear error before
attempting to support it. An untested branch that also refuses to say no is the
hardest kind of debt — it can neither be trusted nor deleted, because nobody
knows who depends on it. Same shape as the offline-first code that a TTL gate
had quietly made unreachable.
Splits the work into three PRs accordingly; import lib support gets its own
design doc, gated on shared-library coverage existing for PE and Mach-O first.
The job builds twice — once for the host, then once for x86_64-linux-musl — and
a cache-restored BMI tree makes the second build read std BMIs that no longer
match what the dependency BMIs were compiled against:
mcpplibs.cmdline: error: import 'std' has CRC mismatch
GCC bakes a CRC of each imported module's BMI into the importer, so the two have
to come from the same build round. A cache that restores one without the other
is not a partial speedup, it is an unbuildable tree.
Systematic, not flaky: it reproduced on rerun, and only in this job. The other
two cross jobs cache ~/.mcpp and ~/.xlings but deliberately never target/ —
this now follows the same convention.
It stayed hidden until a PR touched neither mcpp.toml nor .xlings.json, since
those two files key the sandbox cache; every earlier run had been a cold miss.
That also means it would have reddened every subsequent PR, so it is fixed
ahead of the B3 work rather than alongside it.
Adds ArtifactNaming (a (os, env) function on the target triple) plus the
regression assertion for a defect that exists on HEAD today.
plan.cppm's target_output() spells the artifact suffix from
mcpp::platform::exe_suffix — a HOST constant. Cross-compiling Linux -> PE that
yields `bin/foo` while mingw's GCC driver writes `bin/foo.exe`, so the file
ninja was told to produce never appears. ninja finds the declared output
missing on every run and reruns the link edge forever.
The e2e asserts both the cause and its observable consequence: that the
declared ninja output exists, and that an up-to-date rebuild does not change
the artifact's mtime.
Verified RED before the fix:
FAIL: ninja declares output 'bin/relinkprobe' but that file does not exist
actually produced: relinkprobe.exe
=> the link edge can never be satisfied, so it reruns every build
The unit tests cover ArtifactNaming's own logic, including the part a single
_WIN32 branch cannot express: windows-gnu uses the GNU convention (libfoo.a)
while windows-msvc uses foo.lib. They pass a deliberately bogus host answer, so
any assertion leaking through to the host axis fails loudly.
None of the other cross tests could have caught this: they look for the REAL
artifact (find -name '*.exe'), not for what ninja declared, so both of their
assertions hold while the inconsistency sits underneath them.
Refs .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md
target_output() spelled the suffix and library affixes from
mcpp::platform::{exe_suffix,lib_prefix,static_lib_ext,shared_lib_ext} — host
constants selected by #if defined(_WIN32)/__APPLE__. On a host build the host
and target answers coincide, which is why it survived; they diverge the moment
host != target.
The consequence was not cosmetic. Cross-compiling Linux -> PE, ninja was told
to produce `bin/foo` while mingw's GCC driver writes `bin/foo.exe`, so the
declared output never existed and ninja reran the link edge on every single
build. Incremental builds were effectively off for PE targets — the path CI
exercises daily.
Naming now comes from ArtifactNaming, resolved once per plan from the target
triple. It is an (os, env) function, not an os one:
x86_64-windows-gnu -> libfoo.a (GNU/mingw)
x86_64-windows-msvc -> foo.lib (MSVC)
A single _WIN32 branch cannot express that, which is why building a static
library with mingw ON a Windows host produced `foo.lib` — a GNU archive wearing
an MSVC name. That is a behaviour change for that configuration, and it fixes a
name that was already wrong.
An empty triple means "build for this machine", and only there is the host
answer correct, so it is threaded in as the fallback rather than read directly.
Host builds are therefore bit-for-bit unchanged.
shared_library_link_flags gets the same treatment: whether a consumer links a
full path (PE), uses @loader_path (Mach-O) or $ORIGIN (ELF) is a property of
what we build FOR. Keying it on the host pointed it the wrong way under cross
builds.
Also refuses SharedLibrary on non-ELF targets. Every shared-library e2e
declares `# requires: elf` and run_all.sh grants that only on Linux, so those
paths have never been verified on PE or Mach-O — mingw's ld tolerates linking a
.dll directly, MSVC's link.exe cannot, and neither has an import library
because mcpp does not model one. A clear refusal beats emitting an artifact
nothing has ever checked.
Verified:
- e2e 183 red before, green after
- 53 unit tests pass
- 08_shared_library, 64_shared_soname_runtime_alias,
55/57_*_shared_artifact, 102_mingw_cross_wine all pass — the soname alias
edge is the one the design doc flagged as historically fragile
Refs .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md
Bumps the BUILDING pair only (mcpp.toml + fingerprint.cppm); the bootstrap pin in .xlings.json stays at 2026.8.3.2 until this release exists and is reachable through the index. CHANGELOG calls out the windows-gnu static library rename explicitly: it is the one change here that alters a HOST build's output name (foo.lib -> libfoo.a on Windows + mingw), and it corrects a name that was already wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements B3 — filed as a follow-up in #339, written up in
.agents/docs/2026-08-03-b3-target-aware-artifact-naming.md(included here).Supersedes #341, which was docs-only and could not go green until the CI fix
below landed.
The defect is not cosmetic
plan.cppm'starget_output()spelled artifact names frommcpp::platform::{exe_suffix,lib_prefix,static_lib_ext,shared_lib_ext}— hostconstants chosen by
#if defined(_WIN32)/__APPLE__. Host builds make the twoquestions coincide, so it survived; they diverge the moment host != target.
Cross-compiling Linux → PE, ninja was told to produce
bin/foowhile mingw's GCCdriver writes
bin/foo.exe:The declared output never exists, so ninja reran the link edge on every build.
Incremental builds were effectively off for PE targets — the path CI exercises
daily.
The original §6.5 write-up in #339 described this as "symmetrically wrong"
(
mcppfor a PE). That was measured and found false — mingw's driver appends.exeitself. The doc correcting it is in this PR.Naming is an (os, env) function
A single
_WIN32branch cannot express that — which is why building a staticlibrary with mingw on a Windows host produced
foo.libtoday: a GNU archivewearing an MSVC name. That is a host-build behaviour change in this PR, and it
fixes a name that was already wrong. Called out in the CHANGELOG.
Empty triple = "build for this machine", and only there is the host answer
correct, so it is threaded in as the fallback rather than read directly. Host
builds are bit-for-bit unchanged.
Also: refuse shared libraries on non-ELF targets
All five shared-library e2e tests declare
# requires: elf, andrun_all.shgrants that capability only on Linux. Shared libraries have therefore never
been verified end to end on PE or Mach-O, while the code carried branches that
looked like they were: mingw's
ldtolerates linking a.dlldirectly, MSVC'slink.execannot, and neither has an import library because mcpp does not modelone.
A branch that is neither tested nor willing to say no is the hardest kind of debt
— it can be neither trusted nor deleted. So it now says no, with a message that
names the alternative. Import-library support gets its own design, gated on that
coverage existing first.
CI fix (first commit, independent)
The
windows→linuxjob restoredtarget/viabootstrap-mcpp. That job buildstwice — host, then cross — and a restored BMI tree makes the second build read
stdBMIs that no longer match the dependency BMIs:Systematic, not flaky — reproduced on rerun, and only in this job; the other two
cross jobs deliberately never cache
target/. It stayed hidden until a PRtouched neither
mcpp.tomlnor.xlings.json(those key the sandbox cache), soit would have reddened every subsequent PR. Fixed ahead of the B3 work.
Verification
Commit 1 adds the assertion and is verified red on its own, so "the test actually
discriminates" is a checkable fact in history rather than a claim.