Skip to content

fix(build): name artifacts from the target — cross builds relinked every time (B3) - #342

Merged
Sunrisepeak merged 6 commits into
mainfrom
fix/cross-job-no-target-cache
Aug 3, 2026
Merged

fix(build): name artifacts from the target — cross builds relinked every time (B3)#342
Sunrisepeak merged 6 commits into
mainfrom
fix/cross-job-no-target-cache

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

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's target_output() spelled artifact names from
mcpp::platform::{exe_suffix,lib_prefix,static_lib_ext,shared_lib_ext}host
constants chosen by #if defined(_WIN32)/__APPLE__. Host builds make the two
questions coincide, so it survived; they diverge the moment host != target.

Cross-compiling Linux → PE, ninja was told to produce bin/foo while mingw's GCC
driver writes bin/foo.exe:

$ test -f …/bin/relinkprobe && echo EXISTS || echo MISSING
MISSING                      # produced: relinkprobe.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"
(mcpp for a PE). That was measured and found false — mingw's driver appends
.exe itself. The doc correcting it is in this PR.

Naming is an (os, env) function

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 today: a GNU archive
wearing 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, and run_all.sh
grants 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 ld tolerates linking a .dll directly, MSVC's
link.exe cannot, and neither has an import library because mcpp does not model
one.

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→linux job restored target/ via bootstrap-mcpp. That job builds
twice — host, then cross — and a restored BMI tree makes the second build read
std BMIs that no longer match the dependency BMIs:

mcpplibs.cmdline: error: import 'std' has CRC mismatch

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 PR
touched neither mcpp.toml nor .xlings.json (those key the sandbox cache), so
it would have reddened every subsequent PR. Fixed ahead of the B3 work.

Verification

e2e 183 (new) red before, green after — asserts both the cause (ninja's declared output exists) and the consequence (no relink on rebuild)
unit 53 passed, 0 failed
shared-library e2e 08, 55, 57, 64 all pass — including the soname alias edge the design doc flagged as historically fragile
mingw cross 102_mingw_cross_wine passes

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.

… 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.
@Sunrisepeak
Sunrisepeak merged commit 5d71824 into main Aug 3, 2026
18 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/cross-job-no-target-cache branch August 3, 2026 06:46
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.

1 participant