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 identifierslinux-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.