No one to borrow from
Packaging .NET 8 for Mageia had a shortcut: Fedora had already done the heavy lifting, and my job was adapting their battle-tested spec to a different distribution. .NET 9 and .NET 10 followed the same pattern — wait for Fedora, port, adjust.
This time there is no shortcut. Fedora has no dotnet11.0 package. Their
dist-git stops at dotnet10.0, Koji stops at dotnet10.0, and since .NET 11 is
still preview, nobody downstream has moved yet. So dotnet11 is a forward-port
of our own dotnet10.0 spec — and it turns out that inheriting your own
assumptions is more dangerous than inheriting somebody else's, because you
trust them.
Nineteen clean-chroot builds over three days. Fifteen genuine failures. The spec is 1,421 lines and produces fifteen binary RPMs from a single source tree. Here is what went wrong, in order, including the two occasions I fixed something that was never broken.
What upstream took away
Building .NET from source means running Microsoft's VMR pipeline: one repository containing about twenty sub-repositories — the runtime, Roslyn, MSBuild, F#, ASP.NET Core, the SDK — that bootstraps itself with a previous SDK and rebuilds the whole stack. For .NET 10 this was expensive. We hand-assembled a 1.45 GB bootstrap tarball out of Fedora binary RPMs, force-fed a 1.28 GB prebuilts archive that existed only in a lookaside cache, and maintained prebuilt-baseline gates that had to be regenerated by hand whenever the dependency closure shifted.
.NET 11 deleted almost all of it. Upstream now publishes source-built bootstrap assets directly, and most of the machinery I was braced to port forward simply had no reason to exist:
| Machinery carried in .NET 10 | Status | Why |
|---|---|---|
| Hand-built 1.45 GB hybrid bootstrap | gone | upstream ships a real source-built SDK |
| 1.28 GB prebuilts archive | gone | PrivateSourceBuiltPrebuiltsVersion removed; the URL 404s |
NuGet cache seeder (~90 lines of %build) |
gone | existed only to work around the archive above |
| Prebuilt-baseline gates + generator | gone | no ValidateUsageAgainstBaseline target anywhere in the tree |
| Runtime-pack RID aliasing script | gone | bootstrap RID is now sane |
| CDN crossgen2 packs | still needed | for an entirely different reason |
I removed all of it in the first draft, on the principle that a workaround you cannot explain is a workaround you should delete. That was right. What I did not anticipate was that the one survivor on that list would turn out to be a symptom of the problem that ate the next two days.
Mageia is not on the map
.NET identifies platforms with runtime identifiers — linux-x64,
centos.10-x64, fedora.45-x64 — arranged in a graph where each node imports
its more general parents. The SDK walks that graph to decide which native
packages to fetch for the machine it is building for.
Mageia is not in the graph. Not as a node, not as an alias, nowhere. When
the SDK asks "what is the best matching RID for mageia.11-x64?", it finds
nothing, silently falls back to the portable linux-x64, and then requests
packages under a name this build will never produce.
The failures this generates do not look like one bug. They look like ten unrelated bugs, arriving one per build, each in a different sub-repository, each with a different error code. Restore failures. Missing apphost packs. F# build tools refusing to compile themselves ahead-of-time. It took me until build four to understand I was looking at a single fault with many faces.
An error naming a package does not mean the restore failed. Build 3 died with something NuGet-shaped, so I staged the missing package into the local feed. Build 4 proved that wrong: zero restore errors, the package was never fetched, and the identical failure came back. The name came from pack resolution, not from a
PackageReference.
The fix was to teach the bootstrap SDK about Mageia — patch its RID graph and the version-props file that lists which RIDs each known pack supports. Patching a downloaded bootstrap SDK is legitimate; the artifact that must never be modified is the upstream source tarball, and that stayed untouched.
Getting the patch right took builds five through twelve, because the property I was rewriting turned out to be load-bearing in more places than I had modelled. Each repository that read it broke in its own way, one per build:
| Build | What broke | Why |
|---|---|---|
| 5 | ilasm / ildasm | same property, read case-insensitively in a targets file I had not grepped |
| 6 | narrowed too far | fixed ilasm, broke the AOT compiler — both sides of an equality must agree |
| 7 | pinned a decoy | set a property that nothing in the entire tree reads. No change whatsoever |
| 8 | malformed graph node | I invented an intermediate node; real entries are flat |
| 9 | a second validator | the graph was not enough; a separate RID list needed the same entry |
| 10 | pack does not exist | resolution now succeeded — for a package nobody publishes |
Build 7 is the one I would like back. I spent a whole cycle pinning
MicrosoftNetCoreIlasmPackageRuntimeId, a property that appears in the VMR,
looks exactly like the thing you want, and is read by absolutely nothing. The
real consumer was a differently-cased property in a file two directories away.
A single grep across the tree would have shown me that in ten seconds, and I
did not run it because the name was so plausible.
Find the consumer by grepping for what the failing target reads, not for the property named in nearby code. Plausibility is not evidence.
Forging packages, and breaking them
Once the SDK accepted mageia.11-x64, it started asking for native packages
under that name — an apphost pack, a runtime pack, an ILCompiler pack — at the
bootstrap version. Those do not exist and never will. The archive ships eleven
such packages, all under centos.10-x64. So I wrote a script to make renamed
copies, and this is where I made the most instructive mistake of the whole
exercise, twice.
The first version rewrote the package name and the manifest, repacked everything
with deflate compression, and produced eleven packages that NuGet rejected
outright. A .nupkg is a zip, and its embedded signature file must be stored
— compression method zero. Recompressing it invalidates the package. The fix
was to drop the signature entirely and preserve each entry's original
compression method rather than choosing one.
The second version fixed that, aliased all eleven cleanly, and failed 392 times
in build 14. I had renamed the zip entries and the manifest — but not the
contents of data/RuntimeList.xml, which lists every asset by path and still
carried 196 references to the old RID. My verification checked entry names. It
never opened a file.
392x MSB3030: could not copy
runtimes/centos.10-x64/native/libcoreclr.so
— file does not exist
If you rewrite an identifier, rewrite it everywhere it can appear — including inside files. A manifest is content, not metadata.
Build 15 was the fifth appearance of the same underlying pattern, and the point at which I finally changed method. Instead of fixing the one instance the log named and rebuilding, I searched the tree for every project sharing that shape. There were exactly two. I fixed both in one pass, and that pass was the last spec change before green.
When a failure recurs in a new location for the third time, stop fixing instances. Enumerate the set, then fix the set. An eight-hour feedback loop punishes nothing so much as fixing them one at a time.
Thirty-six minutes
Build 18 went green. Fifteen packages, zero errors, status.log reporting
ok. The VMR build took thirty-six minutes and forty seconds.
.NET 10 took roughly eight hours on this same laptop. A gap that large is not a
pleasant surprise; it is a claim that needs evidence, because a build that
silently reused prebuilt binaries would look identical in every log line I
habitually check. Sub-repositories reporting done proves nothing — a cached
artifact reports done too.
The conclusive test took one command and no log reading at all. Cauldron currently carries glibc 2.43; Microsoft's published binaries are built against a floor of 2.27, because they have to run on everything:
$ objdump -p libcoreclr.so | grep -oE 'GLIBC_[0-9.]+' | sort -uV | tail -3
GLIBC_2.38
GLIBC_2.43
GLIBC_2.44
Symbols that exist only in Cauldron's glibc — a downloaded binary cannot require them. That library was linked in this chroot, today. The speed was real, and the explanation was the table at the top of this post: .NET 10 spent those hours inside the prebuilts machinery that .NET 11 deleted. Every one of my failed builds had died in the first few repositories, so I had never once seen a complete run and had no idea what one should cost.
An unexpected duration is a prompt to verify, not evidence of a defect. Verify with a property the artifact cannot fake — not with a log line it can.
Builds 16 and 17 belong here as a footnote. No error of any kind — No failure marker, No exception. It was me making late-night working instead of sleeping.
A version number is not a version number
.NET 11 is preview software. The SDK version is 11.0.100-preview.7.26381.103,
and RPM cannot hold that in a Version: field, because the hyphen is the
separator between version and release.
Fedora solves this by folding the suffix into the version with a tilde:
11.0.100~preview.7, which sorts below the eventual 11.0.100. It works, I
had verified the ordering, and it was wrong here — because it is Fedora's
idiom, not ours. Mageia keeps the base version in Version: and puts the
prerelease in the release tag, through the -c flag of our own %mkrel
macro. A review caught what I had missed; reading actual packages in cauldron
SVN confirmed it. aalib, upstream version 1.4rc5, ships as:
Version: 1.4.0
Release: %mkrel -c %{rcver} 37 # → 0.rc5.37.mga11
The leading 0. is what sorts a prerelease below its GA. One trap on the way:
the obvious conditional, %{?prerel:-c %{prerel}}, tests whether the macro is
defined — and a %global is always defined, even when empty. At GA it would
emit a dangling -c and die. The kernel package solves it with a length test,
so I did too, which means the whole thing collapses on its own when the previews
end:
%global prerel %(echo %{sdk_version} | cut -s -d- -f2-)
%if 0%(echo -n %{prerel} | wc -c)
%global rpmrel %(echo %mkrel -c %{prerel} %{baserel})
%else
%global rpmrel %(echo %mkrel %{baserel})
%endif
# preview → 11.0.100-0.preview.7.26381.103.1.mga11
# GA → 11.0.100-1.mga11
Rebuilding under the corrected scheme gave build 19 — and a useful confirmation that the change was purely cosmetic. Same twenty repositories, build time 36:33 against 36:40, and rpmlint reporting an identical 1,128 warnings. Three numbers unchanged is decent evidence that a packaging-metadata change stayed in the packaging metadata.
The bug I did not have to build to find
With x86_64 green, ARM was next. The x86_64 path was working, the aliasing script was arch-agnostic, and I could have submitted it. Two greps stopped me.
On aarch64 the bootstrap SDK reports its RID as linux-arm64, so the aliasing
script would look for packages under that name — and the archive contains none,
because upstream publishes no ARM source-built assets. It would abort in
%prep. That failure is loud, immediate and harmless.
The second problem is the dangerous one. Had I "helpfully" made the script fall back to the x86-64 packages and rename those, it would have succeeded. It would have produced ARM-named packages full of x86-64 machine code, and the build would have failed much later, somewhere with no obvious connection to the cause. A RID in a filename says nothing about the architecture of the code inside it — and the difference is two bytes at offset 0x12 of the ELF header:
what the archive actually contains
Microsoft.NETCore.App.Host.centos.10-x64
runtimes/…/native/apphost e_machine 0x3e x86-64
what a native arm64 pack contains
Microsoft.NETCore.App.Host.linux-arm64
runtimes/…/native/apphost e_machine 0xb7 aarch64
Scoping the fix was the interesting half. Rather than sourcing ARM equivalents for all eleven packages, I searched the successful x86_64 log for which aliased packages were actually requested at the bootstrap version — as opposed to the target version, where they are this build's own output. Two. The runtime pack, 426 references, and the host pack, 120. The other nine were never asked for, which also meant the two unavailable for ARM did not matter.
So the ARM path fetches two native packages, and %prep now refuses to
continue if their ELF headers are not aarch64. I tested that guard by feeding
it a genuine x86-64 package under an ARM filename, and confirmed it exits with
the offending file named.
Write the guard, then prove it fails. A guard you have never seen fire is an assumption wearing a high-visibility jacket.
Where it stands
| Binary RPMs | 15 from one 1,421-line spec |
| VMR build | 36:33 — 20 sub-repositories |
| rpmlint | 0 errors, 1,128 warnings |
| Runtime ID | mageia.11-x64, as intended |
The x86_64 package is done and verified. The ARM path is implemented, statically
checked as far as this machine allows, and unproven — I cannot build it here,
because iurt takes its architecture from the host and offers no aarch64
target. The official build system has real ARM workers, which is how .NET 10's
ARM build happened too, so that code gets its first genuine execution when the
package is submitted. I would rather say that plainly than imply a coverage I
do not have.
Whether a preview release belongs in Cauldron at all is a separate question.
General availability is expected around November. When it lands, the version
globals lose their -preview.N suffix, the release macro collapses to a plain
%mkrel 1, and nothing else changes — which was the actual deliverable here.
The spec is the durable artifact. The version numbers are just what it happened
to be pointed at this week.
What I will keep from these three days is smaller than the spec and more useful. Grep for what the code reads, not for what it is called. Enumerate the set before fixing the instance. Verify surprises with something that cannot be faked. And write the guard, then break it on purpose — because the only guard worth trusting is one you have watched refuse.
Built in an iurt clean chroot on Mageia Cauldron, on the same laptop that
ran the .NET 8 bootstrap — this time with the power cable firmly attached.