Skip to content

fix: a bare dependency's wire address takes BOTH halves from its descriptor (0.0.109)#286

Merged
Sunrisepeak merged 1 commit into
mainfrom
fix/bare-name-wire-address
Jul 26, 2026
Merged

fix: a bare dependency's wire address takes BOTH halves from its descriptor (0.0.109)#286
Sunrisepeak merged 1 commit into
mainfrom
fix/bare-name-wire-address

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Jul 26, 2026

Copy link
Copy Markdown
Member

Fixes the 2026-07-25 breakage that left main with 6 of 7 workflows red, plus the two other failures that landed in the same batch.

What happened

Nothing in mcpp changed. The PR run for the very same commit was green 80 minutes earlier; in between, mcpp-index#120 migrated all 48 descriptors to SPEC-001 short names (name = "compat.gtest"name = "gtest").

Time (UTC) Event Result
18:58 PR #282 CI — identical mcpp code green
20:10:37 mcpp-index #120 merged
20:18:22 #282 pushed to main 6 of 7 workflows red

Root cause

The identity gate deliberately accepts a compat descriptor for a bare/default-namespace request — that is the only reason gtest = "1.15.2" resolves to compat.gtest at all:

if (ns == kDefaultNamespace) {
    return id.ns == kDefaultNamespace
        || id.ns == kCompatNamespace     // <- compat.gtest.lua accepted here
        || (allowLegacyBareDefault && id.ns.empty());
}

But the wire address then took its name from that descriptor and its namespace from the request:

auto wireName = extract_xpkg_name(*luaContent);            // "gtest"    <- descriptor
auto target   = std::format("{}:{}@{}", ns, wireName, …);  // "mcpplibs" <- request

mcpplibs:gtest is not a key any index has. It only ever worked because the literal package.name used to read compat.gtest, so the hardcoded compat.<short> retry caught it. The migration removed that coincidence.

The main path was always broken and a legacy retry had been carrying it. A latent defect coming due, not a new regression.

Evidence — one CI job, same index, same xlings, same process

compat:zlib@1.3.2        OK      <- [dependencies.compat] zlib
compat:libarchive@3.8.7  OK
mcpplibs:ftxui@6.1.9     FAIL    <- bare [dependencies] ftxui
compat.ftxui@6.1.9       FAIL

Qualified spelling works; bare spelling does not. The correct address is compat:ftxui@6.1.9 and neither attempt was it.

The fix

mcpp::manifest::xpkg_wire_address derives namespace and name together from the descriptor the gate accepted. The rule: the descriptor the identity gate accepted supplies both halves of the address.

Blast radius, checked against every descriptor in the live index:

Descriptor a bare request can resolve to Count After
namespace = "mcpplibs" 8 unchanged
namespace = "compat" 34 mcpplibs:Xcompat:Xall 34 currently broken
no namespace declared 0 edge case does not exist in the real index
other namespaces 6 gate rejects bare requests for them; unreachable

Only the packages that are already broken change. A descriptor that cannot be read keeps the historical derivation byte for byte, so an unsynced index still fails exactly where it used to.

The compat retry now tries compat:<short> before the legacy compat.<short>, so the no-descriptor path works against both index generations.

Second instance of the same defect

mcpp new --template built its target the same way (<ns>.<short>@<ver>, a re-derived FQN) and is equally dead post-migration. Every case in e2e 69 runs off a pre-seeded install cache, so that path had zero coverage — the same shape of blind spot as the index side. Fixed with the same function, plus a sub-case that exercises it.

The other two failures in the batch

e2e 163 built its fixture by sed'ing the literal name = "chriskohlhoff.asio" out of the real registry descriptor. #120 renamed it, the rewrite silently stopped matching, and the fixture carried the wrong identity — a hermetic-looking test with upstream's exact bytes compiled into it. Rewrites now match by shape and hard-fail if they miss.

Unpinned CI bootstraps. Three places bootstrapped mcpp with a bare xlings install mcpp -y, which means "newest in whatever index copy this runner happens to have". .xlings.json fed only the cache key, and restore-keys prefix-matches, so jobs ran 0.0.107, 0.0.102 and 0.0.105 against an index whose floor is 0.0.108. Warm caches hid the E0006; the moment one went cold, the floor check made every descriptor read return nothing and each dependency fell back to its legacy derived address. All three now install the pin from .xlings.json, address that version by its own path rather than through a shim that can point elsewhere, and assert the parsed version matches exactly.

CI's sandbox xlings was 39 minor versions stale. The index's short-name migration put two packages named lua in one repo — compat:lua and mcpplibs.capi:lua, both pulled in transitively by mcpplibs.xpkg. Before xlings 0.4.69 (xlings#381) a repo keyed its table by the bare package.name, so one of the two was simply unreachable, and which one depended on the machine: CI failed on compat:lua on Windows and mcpplibs.capi:lua on Linux.

release.yml and cross-build-test.yml had already moved to 0.4.69 — which is exactly why cross-build-test stayed green while every bootstrap-mcpp job failed. The two composite actions were left behind at 0.4.30. Both are now on 0.4.69.

The version also had to enter the cache lineage, not just the key. mcpp vendors xlings into ~/.mcpp/registry/bin once at self init and never revisits it (acquire_xlings_binary returns early when the file exists), so with the version only in the key, restore-keys would hand a 0.4.30 sandbox to a 0.4.69 bootstrap — and the sandbox copy is the one that actually resolves dependencies. This costs one cold run.

The bootstrap now also prints both the system and sandbox xlings versions. That divergence is invisible today, and it is what made this the hardest part of the diagnosis.

Why the index side was green

Every mcpp-index example spells the dependency [dependencies.compat] + a path index — the one form that was never broken. Real consumers, including mcpp's own mcpp.toml, write it bare. Verification matrix and consumption matrix were not the same shape, so 8 green checks meant nothing. The new e2e builds its own index so it cannot drift, and asserts on the address mcpp sends, not merely on whether the install happened to work.

Verification

  • 9 unit cases over xpkg_wire_address covering every row of the blast-radius table, including the ones that must not change.
  • New e2e 165 reproduces the production bug hermetically. Run against a binary built without the fix it reports the exact production symptom — mcpplibs:widget then compat.widget; with the fix it passes.
  • End-to-end against the live migrated index: a bare eigen = "5.0.1" now resolves in one shot as compat:eigen@5.0.1, no fallback.
  • Full local unit suite (37 binaries) and e2e suite green apart from this machine's known environment failures.

Also surfaces the attempted wire addresses in the install-failure diagnostic — diagnosing this incident required MCPP_VERBOSE=1 to discover what mcpp had actually asked for, while the error named only the dependency, which is the one thing nobody doubts.

Design + plan: .agents/docs/2026-07-26-bare-name-wire-address-{design,implementation-plan}.md

Follow-ups (not in this PR)

@Sunrisepeak
Sunrisepeak force-pushed the fix/bare-name-wire-address branch 4 times, most recently from ffdba04 to 297cd0d Compare July 26, 2026 07:12
…riptor (0.0.109)

main went 6-of-7 workflows red without a line of mcpp changing. The PR run for
the very same commit was green 80 minutes earlier; in between, mcpp-index #120
migrated all 48 descriptors to SPEC-001 short names.

The identity gate deliberately accepts a `compat` descriptor for a bare/default-
namespace request — that is how `gtest = "1.15.2"` resolves to compat.gtest at
all. But the wire address then took its NAME from that descriptor and its
NAMESPACE from the request, emitting `mcpplibs:gtest`, which no index is keyed
by. It only ever worked because the literal `package.name` used to read
`compat.gtest`, so the hardcoded `compat.<short>` retry caught it. The short-name
migration removed that coincidence and 34 of the index's 48 packages became
uninstallable by bare name.

So the main path was always broken and a legacy retry had been carrying it. This
is a latent defect coming due, not a new regression.

Evidence, from one CI job (same index, same xlings, same process):

    compat:zlib@1.3.2       OK      <- [dependencies.compat] zlib
    compat:libarchive@3.8.7 OK
    mcpplibs:ftxui@6.1.9    FAIL    <- bare [dependencies] ftxui
    compat.ftxui@6.1.9      FAIL

Fix: `mcpp::manifest::xpkg_wire_address` derives ns and name together from the
descriptor the gate accepted, and prepare.cppm uses it. Blast radius, checked
against every descriptor in the live index: the 8 mcpplibs packages address
exactly as before, the 6 under other namespaces are unreachable by bare name
anyway, and the 34 compat ones — all currently broken — start working. A
descriptor that cannot be read keeps the historical derivation byte for byte, so
an unsynced index still fails where it used to.

The compat retry now tries `compat:<short>` before the legacy `compat.<short>`,
so the no-descriptor path works against both index generations.

Two more failures in the same batch, same trigger:

* e2e 163 built its fixture by sed'ing the literal `name = "chriskohlhoff.asio"`
  out of the real registry descriptor. #120 renamed it, the rewrite silently
  stopped matching, and the fixture carried the wrong identity — a hermetic-
  looking test that had upstream's exact bytes compiled into it. Rewrites now
  match by shape and hard-fail if they miss.

* bootstrap-mcpp ran `xlings install mcpp -y` with no pin. `.xlings.json` fed
  only the cache key, and restore-keys prefix-matches, so jobs ran 0.0.107 and
  0.0.102 against an index whose floor is 0.0.108. Warm caches hid the E0006; a
  cold one would not have. It now installs the pin and addresses that version by
  path rather than through a shim that can point elsewhere.

Verification:
  * 9 unit cases over xpkg_wire_address covering every row of the blast-radius
    table, including the ones that must NOT change.
  * New e2e 165 reproduces the production bug hermetically: a bare dep served by
    a compat descriptor from a local fixture index. Run against a binary built
    without the fix it reports the exact production symptom
    (mcpplibs:widget then compat.widget); with the fix it passes.
  * End-to-end against the live migrated index: a bare `eigen = "5.0.1"` now
    resolves in one shot as `compat:eigen@5.0.1`, no fallback.

Design + plan: .agents/docs/2026-07-26-bare-name-wire-address-{design,implementation-plan}.md
@Sunrisepeak
Sunrisepeak force-pushed the fix/bare-name-wire-address branch from 297cd0d to abc494b Compare July 26, 2026 07:17
@Sunrisepeak
Sunrisepeak merged commit 6f488e8 into main Jul 26, 2026
15 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/bare-name-wire-address branch July 26, 2026 07:28
Sunrisepeak added a commit that referenced this pull request Jul 26, 2026
The 0.0.109 release died in `build + upload (linux/x86_64)` with the symptom
0.0.109 was released to fix:

    error: index requires mcpp >= 0.0.108 but this is mcpp 0.0.105 [E0006]
    error: xlings install_packages failed (exit 1) for 'mcpplibs.cmdline@0.0.1'

A bare `xlings install mcpp -y` resolves "newest in whatever index copy this
runner happens to have", not the pin in .xlings.json. When that lands below the
index floor, the floor check makes EVERY descriptor read return nothing, so
every dependency falls back to its legacy derived address — and the error names
neither the version nor the floor as the cause.

#286 pinned two composite actions. It missed that release.yml carries four
inline bootstraps of its own, cross-build-test.yml two more, and each had
drifted: the Windows one used `find ~/.xlings -name mcpp.exe | head -1`, which
returns whichever version the directory walk reaches first.

So: one implementation. .github/tools/install_pinned_mcpp.sh reads the pin,
installs it, locates the binary inside that version's own directory (the layout
underneath differs per platform — `<ver>/mcpp` on Linux, `<ver>/bin/mcpp.exe` on
Windows — so only the version directory is treated as the contract), asserts the
parsed version matches exactly, and prints just the path. Six call sites use it,
including the two from #286, so there is no seventh copy to drift.

Two bugs found while consolidating, both of which #286 had shipped:

* Under `set -eo pipefail` a `find ~/.xlings ... 2>/dev/null` over a tree with
  one unreadable directory exits non-zero — the redirect hides the message, not
  the status. That killed the script before it could report anything. Every
  find is now `|| true` with an explicit `return 0`, since a miss is a normal
  outcome the caller handles. #286's copies had the same landmine; it only
  stayed quiet because the loop returned early before reaching the fallback.

* An exact version comparison instead of a substring match, which would let a
  pin of 0.0.10 be satisfied by a 0.0.109 binary.

The Windows release leg also never returns to the workspace after `cd "$WORK"`,
so it reads .xlings.json through a REPO_DIR captured beforehand rather than
GITHUB_WORKSPACE, which git-bash sees as a backslash path.

Left alone deliberately: ci-aarch64-fresh-install's bare install is the point of
that workflow (it verifies what a user's fresh install resolves), and
ci-linux-e2e's hermetic bootstrap is green — reshaping a passing job for
stylistic consistency after six CI rounds is not a good trade.

No source change; 0.0.109 is unreleased, so the version stays.
Sunrisepeak added a commit that referenced this pull request Jul 26, 2026
The 0.0.109 release died in `build + upload (linux/x86_64)` with the symptom
0.0.109 was released to fix:

    error: index requires mcpp >= 0.0.108 but this is mcpp 0.0.105 [E0006]
    error: xlings install_packages failed (exit 1) for 'mcpplibs.cmdline@0.0.1'

A bare `xlings install mcpp -y` resolves "newest in whatever index copy this
runner happens to have", not the pin in .xlings.json. When that lands below the
index floor, the floor check makes EVERY descriptor read return nothing, so
every dependency falls back to its legacy derived address — and the error names
neither the version nor the floor as the cause.

#286 pinned two composite actions. It missed that release.yml carries four
inline bootstraps of its own, cross-build-test.yml two more, and each had
drifted: the Windows one used `find ~/.xlings -name mcpp.exe | head -1`, which
returns whichever version the directory walk reaches first.

So: one implementation. .github/tools/install_pinned_mcpp.sh reads the pin,
installs it, locates the binary inside that version's own directory (the layout
underneath differs per platform — `<ver>/mcpp` on Linux, `<ver>/bin/mcpp.exe` on
Windows — so only the version directory is treated as the contract), asserts the
parsed version matches exactly, and prints just the path. Six call sites use it,
including the two from #286, so there is no seventh copy to drift.

Two bugs found while consolidating, both of which #286 had shipped:

* Under `set -eo pipefail` a `find ~/.xlings ... 2>/dev/null` over a tree with
  one unreadable directory exits non-zero — the redirect hides the message, not
  the status. That killed the script before it could report anything. Every
  find is now `|| true` with an explicit `return 0`, since a miss is a normal
  outcome the caller handles. #286's copies had the same landmine; it only
  stayed quiet because the loop returned early before reaching the fallback.

* An exact version comparison instead of a substring match, which would let a
  pin of 0.0.10 be satisfied by a 0.0.109 binary.

The Windows release leg also never returns to the workspace after `cd "$WORK"`,
so it reads .xlings.json through a REPO_DIR captured beforehand rather than
GITHUB_WORKSPACE, which git-bash sees as a backslash path.

Left alone deliberately: ci-aarch64-fresh-install's bare install is the point of
that workflow (it verifies what a user's fresh install resolves), and
ci-linux-e2e's hermetic bootstrap is green — reshaping a passing job for
stylistic consistency after six CI rounds is not a good trade.

No source change; 0.0.109 is unreleased, so the version stays.
Sunrisepeak added a commit that referenced this pull request Jul 26, 2026
The 0.0.109 release died in `build + upload (linux/x86_64)` with the symptom
0.0.109 was released to fix:

    error: index requires mcpp >= 0.0.108 but this is mcpp 0.0.105 [E0006]
    error: xlings install_packages failed (exit 1) for 'mcpplibs.cmdline@0.0.1'

A bare `xlings install mcpp -y` resolves "newest in whatever index copy this
runner happens to have", not the pin in .xlings.json. When that lands below the
index floor, the floor check makes EVERY descriptor read return nothing, so
every dependency falls back to its legacy derived address — and the error names
neither the version nor the floor as the cause.

#286 pinned two composite actions. It missed that release.yml carries four
inline bootstraps of its own, cross-build-test.yml two more, and each had
drifted: the Windows one used `find ~/.xlings -name mcpp.exe | head -1`, which
returns whichever version the directory walk reaches first.

So: one implementation. .github/tools/install_pinned_mcpp.sh reads the pin,
installs it, locates the binary inside that version's own directory (the layout
underneath differs per platform — `<ver>/mcpp` on Linux, `<ver>/bin/mcpp.exe` on
Windows — so only the version directory is treated as the contract), asserts the
parsed version matches exactly, and prints just the path. Six call sites use it,
including the two from #286, so there is no seventh copy to drift.

Two bugs found while consolidating, both of which #286 had shipped:

* Under `set -eo pipefail` a `find ~/.xlings ... 2>/dev/null` over a tree with
  one unreadable directory exits non-zero — the redirect hides the message, not
  the status. That killed the script before it could report anything. Every
  find is now `|| true` with an explicit `return 0`, since a miss is a normal
  outcome the caller handles. #286's copies had the same landmine; it only
  stayed quiet because the loop returned early before reaching the fallback.

* An exact version comparison instead of a substring match, which would let a
  pin of 0.0.10 be satisfied by a 0.0.109 binary.

The Windows release leg also never returns to the workspace after `cd "$WORK"`,
so it reads .xlings.json through a REPO_DIR captured beforehand rather than
GITHUB_WORKSPACE, which git-bash sees as a backslash path.

Left alone deliberately: ci-aarch64-fresh-install's bare install is the point of
that workflow (it verifies what a user's fresh install resolves), and
ci-linux-e2e's hermetic bootstrap is green — reshaping a passing job for
stylistic consistency after six CI rounds is not a good trade.

No source change; 0.0.109 is unreleased, so the version stays.
Sunrisepeak added a commit that referenced this pull request Jul 26, 2026
ci: one pinned-mcpp bootstrap, not six drifting copies

The 0.0.109 release died in `build + upload (linux/x86_64)` with the symptom
0.0.109 was released to fix:

    error: index requires mcpp >= 0.0.108 but this is mcpp 0.0.105 [E0006]
    error: xlings install_packages failed (exit 1) for 'mcpplibs.cmdline@0.0.1'

A bare `xlings install mcpp -y` resolves "newest in whatever index copy this
runner happens to have", not the pin in .xlings.json. When that lands below the
index floor, the floor check makes EVERY descriptor read return nothing, so
every dependency falls back to its legacy derived address — and the error names
neither the version nor the floor as the cause.

#286 pinned two composite actions. It missed that release.yml carries four
inline bootstraps of its own, cross-build-test.yml two more, and each had
drifted: the Windows one used `find ~/.xlings -name mcpp.exe | head -1`, which
returns whichever version the directory walk reaches first.

So: one implementation. .github/tools/install_pinned_mcpp.sh reads the pin,
installs it, locates the binary inside that version's own directory (the layout
underneath differs per platform — `<ver>/mcpp` on Linux, `<ver>/bin/mcpp.exe` on
Windows — so only the version directory is treated as the contract), asserts the
parsed version matches exactly, and prints just the path. Six call sites use it,
including the two from #286, so there is no seventh copy to drift.

Two bugs found while consolidating, both of which #286 had shipped:

* Under `set -eo pipefail` a `find ~/.xlings ... 2>/dev/null` over a tree with
  one unreadable directory exits non-zero — the redirect hides the message, not
  the status. That killed the script before it could report anything. Every
  find is now `|| true` with an explicit `return 0`, since a miss is a normal
  outcome the caller handles. #286's copies had the same landmine; it only
  stayed quiet because the loop returned early before reaching the fallback.

* An exact version comparison instead of a substring match, which would let a
  pin of 0.0.10 be satisfied by a 0.0.109 binary.

The Windows release leg also never returns to the workspace after `cd "$WORK"`,
so it reads .xlings.json through a REPO_DIR captured beforehand rather than
GITHUB_WORKSPACE, which git-bash sees as a backslash path.

Left alone deliberately: ci-aarch64-fresh-install's bare install is the point of
that workflow (it verifies what a user's fresh install resolves), and
ci-linux-e2e's hermetic bootstrap is green — reshaping a passing job for
stylistic consistency after six CI rounds is not a good trade.

No source change; 0.0.109 is unreleased, so the version stays.
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