From f38bf4f4c3831f28f11345a40c1dc92f18d89608 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 04:02:12 +0100 Subject: [PATCH 1/5] ci: assert a declared glibc floor in the binary composition gate check-binary-composition.sh gains A6-glibc-floor. When a venue declares CBM_GLIBC_FLOOR (and optionally CBM_GLIBCXX_FLOOR), no GLIBC_* or GLIBCXX_* symbol version a dynamic ELF requires may be newer than that floor. The names come from the dynamic version tables, which the loader checks at startup and every strip level keeps. Versions compare numerically (2.4 is older than 2.35); GLIBC_ABI_DT_RELR counts as 2.36. Other unnumbered names (GLIBC_PRIVATE) and a dynamic binary that requires no GLIBC_* version at all fail closed. A static ELF passes, Mach-O and PE report n/a, and with no floor declared A6 reports n/a, so local builds, the local artifact smoke and contract stubs on newer distros are unaffected. A malformed floor is a usage error (exit 2). The release candidate derivation contract proves it on the stripped stub candidate: floor 1.0 fails and 999.0 passes on glibc Linux, n/a on Mach-O and PE, fail closed on a non-glibc Linux, exit 2 on a malformed floor. Refs #2486 Signed-off-by: Martin Vogel --- scripts/ci/check-binary-composition.sh | 171 ++++++++++++++++++ ...t_release_candidate_derivation_contract.sh | 46 ++++- 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index 15fbbf666..38e2830e5 100755 --- a/scripts/ci/check-binary-composition.sh +++ b/scripts/ci/check-binary-composition.sh @@ -34,6 +34,13 @@ # reported n/a instead of asserted, because eager binding is a property of # the release link flags, not of the packaging path the fixture exercises. # Only contract tests set it; release derivation never does. +# CBM_GLIBC_FLOOR= declares the oldest glibc a dynamic Linux artifact +# must start on: A6-glibc-floor then asserts that no GLIBC_* symbol version +# the binary requires is newer. CBM_GLIBCXX_FLOOR= (only together +# with CBM_GLIBC_FLOOR) does the same for libstdc++'s GLIBCXX_* versions. +# The floor is a property of the BUILD venue, so only the venues that build +# in the pinned ubuntu:22.04 container declare it (_build.yml build-linux, +# pr.yml glibc-floor); everywhere else A6 is reported n/a. set -euo pipefail case "${1:-}" in @@ -127,6 +134,20 @@ if [ "${#TARGETS[@]}" -eq 0 ]; then echo "FAIL: no binaries or directories given (see --help)" >&2 exit 2 fi +# A malformed floor must stop the run: compared as text it would let any +# version through, and an assertion that cannot fail is not a gate. +for floor_var in CBM_GLIBC_FLOOR CBM_GLIBCXX_FLOOR; do + floor_value="${!floor_var:-}" + if [ -n "$floor_value" ] && + ! printf '%s\n' "$floor_value" | awk '!/^[0-9]+(\.[0-9]+)+$/ { bad = 1 } END { exit bad }'; then + echo "FAIL: $floor_var='$floor_value' is not a dotted version such as 2.35 (see --help)" >&2 + exit 2 + fi +done +if [ -n "${CBM_GLIBCXX_FLOOR:-}" ] && [ -z "${CBM_GLIBC_FLOOR:-}" ]; then + echo "FAIL: CBM_GLIBCXX_FLOOR needs CBM_GLIBC_FLOOR (see --help)" >&2 + exit 2 +fi # ── ELF program-header reader ─────────────────────────────────────── # Resolved lazily: a Windows/macOS-only run must not fail for want of readelf, @@ -251,6 +272,76 @@ bind_now_state() { fi } +# Succeeds when the ELF has a PT_DYNAMIC, i.e. the loader binds it at startup. +has_dynamic_section() { + case "$ELF_READER_KIND" in + readelf) + "$ELF_READER" -lW "$1" 2>/dev/null | + awk '$1 == "DYNAMIC" { found = 1 } END { exit !found }' + ;; + objdump) + "$ELF_READER" -p "$1" 2>/dev/null | + awk '/^Dynamic Section:/ { found = 1 } END { exit !found }' + ;; + esac +} + +# Every GLIBC_* / GLIBCXX_* version name in the ELF's dynamic version tables, +# one per line, sorted and unique. The version-needs table (.gnu.version_r) is +# what the loader checks at startup and survives every strip level. A name can +# sit inside a larger token ("2h(GLIBC_2.17)" for a hidden version), so it is +# matched anywhere in the line rather than as a whole field. +elf_glibc_version_needs() { + case "$ELF_READER_KIND" in + readelf) "$ELF_READER" -VW "$1" 2>/dev/null ;; + objdump) "$ELF_READER" -p "$1" 2>/dev/null ;; + esac | + awk '{ s = $0 + while (match(s, /GLIBC(XX)?_[A-Za-z0-9_.]+/)) { + print substr(s, RSTART, RLENGTH) + s = substr(s, RSTART + RLENGTH) + } }' | + LC_ALL=C sort -u +} + +# Echoes one verdict line for the versions with PREFIX (GLIBC or GLIBCXX) in +# the name list on stdin, against FLOOR: +# none no numbered version with that prefix +# ok every one is <= FLOOR +# newer these are newer than FLOOR +# unnumbered a non-release name (GLIBC_PRIVATE, ...): no +# numbered floor covers it +# GLIBC_ABI_DT_RELR (a -z pack-relative-relocs link) counts as 2.36, the glibc +# release that added it. Numeric field-by-field comparison: as text, 2.4 would +# sort after 2.35. +glibc_floor_verdict() { # prefix, floor + awk -v prefix="$1" -v floor="$2" ' + function newer(a, b, x, y, na, nb, n, i, xi, yi) { + na = split(a, x, "."); nb = split(b, y, ".") + n = na > nb ? na : nb + for (i = 1; i <= n; i++) { + xi = (i <= na) ? x[i] + 0 : 0 + yi = (i <= nb) ? y[i] + 0 : 0 + if (xi != yi) return xi > yi + } + return 0 + } + index($0, prefix "_") == 1 { + version = substr($0, length(prefix) + 2) + if ($0 == "GLIBC_ABI_DT_RELR") version = "2.36" + if (version !~ /^[0-9]+(\.[0-9]+)*$/) { unnumbered = unnumbered " " $0; next } + count++ + if (max == "" || newer(version, max)) max = version + if (newer(version, floor)) over = over " " $0 + } + END { + if (unnumbered != "") print "unnumbered" unnumbered + else if (count == 0) print "none" + else if (over != "") print "newer " max over + else print "ok " max " " count + }' +} + # ── Reporting ─────────────────────────────────────────────────────── # PASS and FAIL both go to stdout so the per-assertion sequence stays in order # in a CI log (stderr would interleave nondeterministically); only the final @@ -531,6 +622,86 @@ EOF printf 'n/a %-22s %s: UI-capable artifact, HTTP server ships here by design\n' \ A5-no-ui-http "$token" fi + + # A6 — glibc floor. The standard Linux binaries link glibc and libstdc++ + # dynamically, so the oldest system they start on is set by the newest + # GLIBC_x.y / GLIBCXX_x.y.z symbol version they require: a property of the + # build venue's headers and toolchain that no compile, test or smoke on that + # venue can see, and that is fatal at startup anywhere older (#2486: built + # on the ubuntu-latest runner, the binary required GLIBC_2.38 and + # GLIBCXX_3.4.32 and refused to start on Ubuntu 22.04). Measured on the + # artifact, like A1-A1d, because a build container is not evidence that the + # bytes inherited its floor. The floor is declared by the venue, never + # assumed: a binary built on a developer's newer distro legitimately needs + # that distro's glibc. + if [ -z "${CBM_GLIBC_FLOOR:-}" ]; then + printf 'n/a %-22s %s: no glibc floor declared (CBM_GLIBC_FLOOR unset)\n' \ + A6-glibc-floor "$token" + elif [ "$fmt" != elf ]; then + printf 'n/a %-22s %s: glibc symbol versions are an ELF property\n' \ + A6-glibc-floor "$token" + elif ! has_dynamic_section "$file"; then + report PASS A6-glibc-floor "$token" \ + "no PT_DYNAMIC: nothing binds at startup, so no glibc version is required" + else + version_needs=$(elf_glibc_version_needs "$file") + glibc_verdict=$(printf '%s\n' "$version_needs" | + glibc_floor_verdict GLIBC "$CBM_GLIBC_FLOOR") + read -r _ verdict_max verdict_rest <"$FLOOR_OUT" 2>&1; then + echo "FAIL: a malformed CBM_GLIBC_FLOOR was accepted" >&2 + exit 1 +elif [[ $? -ne 2 ]]; then + echo "FAIL: a malformed CBM_GLIBC_FLOOR must be a usage error (exit 2)" >&2 + exit 1 +fi +floor_status=0 +CBM_GLIBC_FLOOR=1.0 bash "$GATE" "$STRIPPED" >"$FLOOR_OUT" 2>&1 || floor_status=$? +if [[ "$GOOS" != linux ]]; then + [[ $floor_status -eq 0 ]] && grep -q '^n/a A6-glibc-floor' "$FLOOR_OUT" || { + echo "FAIL: A6-glibc-floor must be reported n/a for a $GOOS binary" >&2 + cat "$FLOOR_OUT" >&2 + exit 1 + } +elif getconf GNU_LIBC_VERSION >/dev/null 2>&1; then + [[ $floor_status -eq 1 ]] && grep -q '^FAIL A6-glibc-floor .*newer than floor 1.0' "$FLOOR_OUT" || { + echo "FAIL: floor 1.0 must fail a glibc-linked binary (A6-glibc-floor)" >&2 + cat "$FLOOR_OUT" >&2 + exit 1 + } + CBM_GLIBC_FLOOR=999.0 CBM_GLIBCXX_FLOOR=999.0 bash "$GATE" "$STRIPPED" >"$FLOOR_OUT" 2>&1 && + grep -q '^PASS A6-glibc-floor .*GLIBC_.* <= floor 999.0' "$FLOOR_OUT" && + grep -q '^PASS A6-glibc-floor .*no GLIBCXX_\* version required' "$FLOOR_OUT" || { + echo "FAIL: floor 999.0 must pass a glibc-linked C binary (A6-glibc-floor)" >&2 + cat "$FLOOR_OUT" >&2 + exit 1 + } +else + [[ $floor_status -eq 1 ]] && grep -q '^FAIL A6-glibc-floor .*requires no GLIBC_' "$FLOOR_OUT" || { + echo "FAIL: a non-glibc Linux binary must fail a declared glibc floor closed" >&2 + cat "$FLOOR_OUT" >&2 + exit 1 + } +fi + +echo "PASS: native stripped/unstripped candidates derive from one linker output and publish atomically; the glibc floor binds on the stripped candidate" From 9e6eeddbac75f006fbe1ad419067888ca8e97283 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 04:02:21 +0100 Subject: [PATCH 2/5] ci: build the standard Linux binaries in a pinned ubuntu:22.04 container The standard (dynamic) Linux release binaries were built on the hosted runners Ubuntu 24.04 userland, so they required GLIBC_2.38 and GLIBCXX_3.4.32 and refused to start on Ubuntu 22.04 (#2486). A new build-linux job in _build.yml builds linux-amd64 and linux-arm64 inside ubuntu:22.04 pinned by the digest of its multi-arch index (sha256:076e7bf8..., resolved 2026-11-02 from Docker Hub, one pin for both arches) with the distro toolchain (GCC 11.4 against glibc 2.35), and declares CBM_GLIBC_FLOOR=2.35 / CBM_GLIBCXX_FLOOR=3.4.30 (what the image provides) for the composition gate, so every release candidate is checked. The hosted runner only hosts the container, so a runner image retirement no longer moves the floor. build-unix keeps the two macOS legs, the portable legs are unchanged, select-package needs the new job and the artifact names stay the same. pr.yml gains a report-only glibc-floor leg: the production binary built in the same image and checked by the composition gate with the same floor whenever the selection holds smoke-ubuntu. It is not in ci-ok needs and has no lane, so it never blocks or passes a merge. The venue parity contract asserts the digest pin, one shared digest for the release and PR legs, both arches, the declared floors and that no standard Linux leg returns to a runner userland; the assertion follows the shape of the contract test in #2573. The release gate chain contract lists build-linux among the native candidate producers. Refs #2573 #2486 Co-authored-by: lorenzozanee Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 108 ++++++++++++++++++---- .github/workflows/pr.yml | 35 +++++++ tests/test_release_gate_chain_contract.sh | 2 +- tests/test_venue_parity_contract.sh | 48 ++++++++++ 4 files changed, 172 insertions(+), 21 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 163376ab0..29a8a352d 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -26,21 +26,13 @@ permissions: attestations: write jobs: + # The standard (dynamic) Linux binaries build in build-linux below, inside + # the pinned Ubuntu 22.04 container; this job builds the two macOS binaries. build-unix: strategy: fail-fast: false matrix: include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ # The macOS minimum (14.0) is pinned in Makefile.cbm, not inherited # from this runner's SDK, so both darwin binaries still start on macOS 14. - os: macos-15 @@ -64,11 +56,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install deps (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') - timeout-minutes: 10 - run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y zlib1g-dev - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" @@ -85,10 +72,6 @@ jobs: scripts/build.sh --with-ui "CC=$CC" "CXX=$CXX" fi - - name: Frontend integrity scan - if: matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-ui.sh - - name: Derive immutable stripped and unstripped release candidates run: | scripts/ci/prepare-release-candidates.sh \ @@ -114,6 +97,91 @@ jobs: path: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }} if-no-files-found: error + # The standard (dynamic) Linux binaries build INSIDE a pinned Ubuntu 22.04 + # container; the hosted runner only hosts it. A dynamic binary requires the + # glibc and libstdc++ symbol versions of the userland it was built on: built + # on the runners' own Ubuntu 24.04 it required GLIBC_2.38 / GLIBCXX_3.4.32 + # and refused to start on Ubuntu 22.04 (#2486). The distro toolchain (GCC 11 + # against glibc 2.35 headers) IS the floor, so it is used as shipped, and the + # image is pinned by the digest of its multi-arch index -- one pin for both + # arches, so the floor cannot drift with the tag, and no hosted runner + # image's retirement date moves it. Resolved 2026-11-02 from Docker Hub + # (library/ubuntu, tag 22.04): registry Docker-Content-Digest == sha256 of + # the index body; bump deliberately, never to a tag. CBM_GLIBC_FLOOR / + # CBM_GLIBCXX_FLOOR make the composition gate (A6) assert the floor on every + # candidate: nothing newer than this image's glibc 2.35 / GLIBCXX_3.4.30. + # pr.yml's glibc-floor leg builds in the same image with the same floor. + build-linux: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + container: + image: ubuntu:22.04@sha256:076e7bf82319619f28dd0aa9c02ab34b055ad37100395b4f49a2ffba04fb5e11 + timeout-minutes: 240 + steps: + # The container starts bare: install what the runner image preinstalls + # for a host build (gcc/g++/make, git for the checkout, python3 for the + # candidate derivation) plus zlib1g-dev, as the host legs did. + - name: Install deps (Ubuntu 22.04 container) + timeout-minutes: 10 + env: + DEBIAN_FRONTEND: noninteractive + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y --no-install-recommends ca-certificates git gcc g++ make python3 zlib1g-dev + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Build release binary with UI + env: + VERSION: ${{ inputs.version }} + run: | + if [ -n "$VERSION" ]; then + scripts/build.sh --with-ui --version "$VERSION" CC=gcc CXX=g++ + else + scripts/build.sh --with-ui CC=gcc CXX=g++ + fi + + - name: Frontend integrity scan + if: matrix.arch == 'amd64' + run: scripts/security-ui.sh + + - name: Derive immutable stripped and unstripped release candidates + env: + CBM_GLIBC_FLOOR: "2.35" + CBM_GLIBCXX_FLOOR: "3.4.30" + run: | + scripts/ci/prepare-release-candidates.sh \ + linux "${{ matrix.arch }}" \ + --binary build/c/codebase-memory-mcp \ + --out-dir release-candidates + scripts/gen-third-party-notices.sh \ + "release-candidates/linux-${{ matrix.arch }}/THIRD_PARTY_NOTICES.md" + + - name: Attest release candidate provenance + if: ${{ inputs.attest }} + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + release-candidates/linux-${{ matrix.arch }}/candidate-provenance.tsv + release-candidates/linux-${{ matrix.arch }}/stripped/* + release-candidates/linux-${{ matrix.arch }}/unstripped/* + release-candidates/linux-${{ matrix.arch }}/THIRD_PARTY_NOTICES.md + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-candidates-linux-${{ matrix.arch }} + path: release-candidates/linux-${{ matrix.arch }} + if-no-files-found: error + build-windows: runs-on: windows-latest timeout-minutes: 240 @@ -304,7 +372,7 @@ jobs: # only choose within that tuple. A hard result on ANY sibling blocks # the all-target release rather than selecting around a real detection. select-package: - needs: [build-unix, build-windows, build-windows-arm64, build-linux-portable] + needs: [build-unix, build-linux, build-windows, build-windows-arm64, build-linux-portable] runs-on: ubuntu-latest timeout-minutes: 330 permissions: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3b4ea807c..2ed425a7c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -289,6 +289,41 @@ jobs: with: mode: pr + # ── glibc floor, REPORT-ONLY: builds the production binary in the SAME + # pinned ubuntu:22.04 container the standard Linux release binaries build + # in (_build.yml build-linux) and runs the composition gate with the same + # declared floor, so a change that needs a newer glibc or libstdc++ symbol + # version -- or stops compiling with that GCC 11 toolchain -- shows up at + # PR time instead of in a release run. Light on purpose: a headless build + # (the shipped composition only swaps src/ui/embedded_stub.c for the + # generated asset data) and no smoke. Deliberately NOT in ci-ok's needs + # and no lane of its own: it never blocks or passes a merge. It runs + # whenever the selection holds smoke-ubuntu, i.e. whenever the production + # binary can change. ── + glibc-floor: + needs: [changes] + if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') }} + runs-on: ubuntu-latest + container: + image: ubuntu:22.04@sha256:076e7bf82319619f28dd0aa9c02ab34b055ad37100395b4f49a2ffba04fb5e11 + timeout-minutes: 45 + steps: + - name: Install deps (Ubuntu 22.04 container) + timeout-minutes: 10 + env: + DEBIAN_FRONTEND: noninteractive + run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y --no-install-recommends ca-certificates git gcc g++ make python3 zlib1g-dev + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build the production binary in the release container + run: scripts/build.sh CC=gcc CXX=g++ + - name: Composition gate with the release glibc floor + env: + CBM_GLIBC_FLOOR: "2.35" + CBM_GLIBCXX_FLOOR: "3.4.30" + run: scripts/ci/check-binary-composition.sh build/c/codebase-memory-mcp + # ── Test-impact SHADOW, REPORT-ONLY: predicts from the code graph which C # suites this change can affect (the latest released binary indexes the # PR checkout; detect_changes at depth 15 against the base, mapped to diff --git a/tests/test_release_gate_chain_contract.sh b/tests/test_release_gate_chain_contract.sh index 31c99041d..570eb917b 100755 --- a/tests/test_release_gate_chain_contract.sh +++ b/tests/test_release_gate_chain_contract.sh @@ -172,7 +172,7 @@ def workflow_jobs(source): build_jobs = workflow_jobs(build_text) select = build_jobs.get("select-package", "") -native_jobs = ["build-unix", "build-windows", "build-windows-arm64", +native_jobs = ["build-unix", "build-linux", "build-windows", "build-windows-arm64", "build-linux-portable"] if not re.search(r"(?m)^ scan_candidates:\s*\n(?: [^\n]*\n)*? default:\s*true\s*$", diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index 8b4b8b639..27e5a7b10 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -261,6 +261,54 @@ for name, pattern, why in REQUIRED: if path.exists() and not re.search(pattern, path.read_text()): failures.append(f"{name}: missing required `{pattern}` — {why}") +# glibc floor (#2486; contract shape from #2573): the standard Linux release +# binaries, amd64 AND arm64, build inside ONE ubuntu:22.04 image pinned by +# digest; the composition gate is told that image's floor; the PR leg builds in +# the same image with the same floor. A tag, a second digest, a Linux leg back +# on a hosted runner's userland or a dropped floor lets the floor drift with no +# other check noticing until a user's loader refuses the binary. +def without_comments(text: str) -> str: + return "\n".join(line for line in text.splitlines() + if not line.lstrip().startswith("#")) + +def job_body(text: str, job: str) -> str: + match = re.search(rf"(?ms)^ {re.escape(job)}:\s*\n(.*?)(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", text) + return match.group(1) if match else "" + +FLOOR_IMAGE = r"ubuntu:22\.04@sha256:[0-9a-f]{64}" +FLOORS = {"CBM_GLIBC_FLOOR": "2.35", "CBM_GLIBCXX_FLOOR": "3.4.30"} +if (workflows / "_build.yml").exists() and (workflows / "pr.yml").exists(): + build_text = without_comments((workflows / "_build.yml").read_text()) + pr_text = without_comments((workflows / "pr.yml").read_text()) + build_linux = job_body(build_text, "build-linux") + floor_images = set() + for leg, body in (("_build.yml build-linux", build_linux), + ("pr.yml glibc-floor", job_body(pr_text, "glibc-floor"))): + image = re.search(rf"(?m)^ container:\s*\n image:\s*({FLOOR_IMAGE})\s*$", body) + if not image: + failures.append(f"{leg}: must run in a container pinned by digest to " + f"ubuntu:22.04 (`image: ubuntu:22.04@sha256:<64 hex>`)") + else: + floor_images.add(image.group(1)) + for var, value in FLOORS.items(): + if not re.search(rf'(?m)^\s+{var}:\s*"{re.escape(value)}"\s*$', body): + failures.append(f"{leg}: must declare {var}: \"{value}\" for the " + f"composition gate (the pinned image's floor)") + if "check-binary-composition.sh" not in body and \ + "prepare-release-candidates.sh" not in body: + failures.append(f"{leg}: must run the composition gate on the " + f"container-built binary") + if len(floor_images) > 1: + failures.append("_build.yml build-linux and pr.yml glibc-floor must pin the SAME " + "ubuntu:22.04 digest: " + ", ".join(sorted(floor_images))) + arches = sorted(re.findall(r"(?m)^ - arch:\s*(\S+)\s*$", build_linux)) + if arches != ["amd64", "arm64"]: + failures.append("_build.yml build-linux: must build exactly the amd64 and arm64 " + f"standard Linux binaries, found {arches}") + if re.search(r"(?m)^\s+goos:\s*linux\s*$", job_body(build_text, "build-unix")): + failures.append("_build.yml build-unix: a standard Linux leg is back on a hosted " + "runner's userland; it belongs in build-linux's pinned container") + # Defender posture: hosted Windows runners have real-time protection # POLICY-LOCKED OFF (verified 2026-07-27: WinDefend starts, Set-MpPreference # accepts, RTP stays off), so runner jobs must NOT gate on enabling it; the From 0fc2d9923b15444f244566acd3d96a238ae689af Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 04:02:21 +0100 Subject: [PATCH 3/5] docs: the standard Linux binary needs glibc 2.35+, not 2.38+ Built in the pinned ubuntu:22.04 container, the standard Linux binary requires at most glibc 2.35 and GLIBCXX_3.4.30. Update the comments that said 2.38+: install.sh, the npm and PyPI wrappers, cli.c, the Homebrew formula, smoke-test.sh, _smoke.yml, check-glibc-compat.sh and the local glibc-floor venue. The installers keep choosing the -portable archive, since a 2.35 floor still excludes Debian 11, RHEL 8 and Ubuntu 20.04. The local smoke-glibc-floor lane keeps its assertion and now says what it proves: a binary built in the noble test image refusing to start there shows the venue is older than the build image. Refs #2486 Signed-off-by: Martin Vogel --- .github/workflows/_smoke.yml | 17 +++++++++-------- install.sh | 6 +++--- pkg/homebrew/Formula/codebase-memory-mcp.rb | 4 ++-- pkg/npm/install.js | 7 ++++--- pkg/pypi/src/codebase_memory_mcp/_cli.py | 7 ++++--- scripts/ci/check-glibc-compat.sh | 14 ++++++++------ scripts/smoke-test.sh | 5 +++-- src/cli/cli.c | 6 +++--- test-infrastructure/Dockerfile.glibc22 | 8 +++++--- test-infrastructure/docker-compose.yml | 9 ++++++--- 10 files changed, 47 insertions(+), 36 deletions(-) diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index ac4ca595d..4c26391a0 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -41,12 +41,13 @@ jobs: # Broad legs are REQUIRED gates (no optional / continue-on-error): # every smoke leg must pass. No `optional` flags anywhere. # - # NOTE: the *dynamic* linux binary links glibc 2.38+ and cannot run on - # older distros by design — older-glibc coverage is the -portable (static) - # binary's job, exercised green by the smoke-linux-portable broad legs - # (ubuntu-22.04 / 22.04-arm). So the dynamic broad legs stay on - # forward-compatible OSes only (macOS); running the dynamic binary on - # ubuntu-22.04 would fail Phase 1 (glibc too old), not a real regression. + # NOTE: the *dynamic* linux binary is built in a pinned Ubuntu 22.04 + # container (_build.yml build-linux) and links glibc 2.35+; the + # composition gate (A6) asserts that floor on the built bytes, so no + # broad leg re-runs it on a 22.04 runner. Older-glibc coverage stays + # the -portable (static) binary's job, exercised green by the + # smoke-linux-portable broad legs (ubuntu-22.04 / 22.04-arm) and the + # glibc 2.31 check below. # Empty since the move off macos-14 (retired 2026-11-02): the darwin-arm64 # artifact is now built AND smoked on macos-15, the core leg, which was # this broad leg. @@ -329,8 +330,8 @@ jobs: # The -portable binary is what all linux install/update paths now deliver; # it MUST start on old glibc (Debian 11 / RHEL 8 / Ubuntu 20.04). Runs it - # in debian:bullseye (glibc 2.31) — the standard dynamic binary would fail - # here with `GLIBC_2.38 not found`. + # in debian:bullseye (glibc 2.31) — the standard dynamic binary (glibc + # 2.35 floor) would not start here. - name: Old-glibc compatibility run: scripts/ci/check-glibc-compat.sh "$RUNNER_TEMP/cbm-artifact/codebase-memory-mcp" diff --git a/install.sh b/install.sh index 9f4b4ec99..928e6f446 100755 --- a/install.sh +++ b/install.sh @@ -164,9 +164,9 @@ else EXT="tar.gz" fi -# Linux ships a fully-static "-portable" build; the standard linux binary -# dynamically links glibc 2.38+ and fails on older distros (Debian 11, RHEL 8, -# Ubuntu 20.04). macOS/Windows have no such variant. +# Linux ships a fully-static "-portable" build; the standard linux binary is +# built on Ubuntu 22.04, dynamically links glibc 2.35+ and fails on older +# distros (Debian 11, RHEL 8, Ubuntu 20.04). macOS/Windows have no such variant. PORTABLE="" [ "$OS" = "linux" ] && PORTABLE="-portable" diff --git a/pkg/homebrew/Formula/codebase-memory-mcp.rb b/pkg/homebrew/Formula/codebase-memory-mcp.rb index 0ab8bf015..dfcdb74e9 100644 --- a/pkg/homebrew/Formula/codebase-memory-mcp.rb +++ b/pkg/homebrew/Formula/codebase-memory-mcp.rb @@ -15,8 +15,8 @@ class CodebaseMemoryMcp < Formula end end - # The static -portable build: the dynamic one needs glibc 2.38, which - # Ubuntu 22.04, Debian 12 and RHEL 9 do not have. + # The static -portable build: the dynamic one is built on Ubuntu 22.04 and + # needs glibc 2.35, which Debian 11, RHEL 8/9 and Ubuntu 20.04 do not have. on_linux do on_arm do url "https://github.com/DeusData/codebase-memory-mcp/releases/download/v#{version}/codebase-memory-mcp-linux-arm64-portable.tar.gz" diff --git a/pkg/npm/install.js b/pkg/npm/install.js index f3ec0e9ef..ab977d802 100644 --- a/pkg/npm/install.js +++ b/pkg/npm/install.js @@ -742,9 +742,10 @@ async function main() { const source = resolveDownloadBase(); fs.mkdirSync(BIN_DIR, { recursive: true }); - // Linux ships a fully-static "-portable" build; the standard linux binary - // dynamically links glibc 2.38+ and fails on older distros. macOS/Windows - // have no such variant. Keep in sync with install.sh / pypi _cli.py / cli.c. + // Linux ships a fully-static "-portable" build; the standard linux binary is + // built on Ubuntu 22.04, dynamically links glibc 2.35+ and fails on older + // distros. macOS/Windows have no such variant. Keep in sync with install.sh / + // pypi _cli.py / cli.c. const variant = platform === 'linux' ? '-portable' : ''; // No UI/standard split since v0.10.0: one archive per platform, graph UI // always embedded. The old CBM_VARIANT=ui opt-in pointed at ui-* archives diff --git a/pkg/pypi/src/codebase_memory_mcp/_cli.py b/pkg/pypi/src/codebase_memory_mcp/_cli.py index e6e870b34..889acf75c 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -1290,9 +1290,10 @@ def _download(version: str) -> Path: os_name = _os_name() arch = _arch() ext = "zip" if os_name == "windows" else "tar.gz" - # Linux ships a fully-static "-portable" build; the standard linux binary - # dynamically links glibc 2.38+ and fails on older distros. macOS/Windows - # have no such variant. Keep in sync with install.sh / install.js / cli.c. + # Linux ships a fully-static "-portable" build; the standard linux binary is + # built on Ubuntu 22.04, dynamically links glibc 2.35+ and fails on older + # distros. macOS/Windows have no such variant. Keep in sync with install.sh / + # install.js / cli.c. portable = "-portable" if os_name == "linux" else "" archive = f"codebase-memory-mcp-{os_name}-{arch}{portable}.{ext}" base, loopback = _download_base(version) diff --git a/scripts/ci/check-glibc-compat.sh b/scripts/ci/check-glibc-compat.sh index ed0b8ac50..89634fa69 100755 --- a/scripts/ci/check-glibc-compat.sh +++ b/scripts/ci/check-glibc-compat.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash # Regression guard: the linux binary we ship must START on OLD glibc. # -# The standard linux release binary dynamically links glibc 2.38+ / GLIBCXX -# 3.4.32 and fails to start on Debian 11, RHEL/Rocky 8, Ubuntu 20.04, Amazon -# Linux 2, etc. The fix points all linux install + self-update paths at the -# fully-static "-portable" asset. This runs a given linux binary inside an -# old-glibc container (debian:bullseye, glibc 2.31) and asserts it starts: -# - RED for the dynamic standard binary (GLIBC_2.38 not found) +# The standard linux release binary is built in a pinned Ubuntu 22.04 container +# and dynamically links glibc / libstdc++ with a 2.35 / GLIBCXX_3.4.30 floor +# (asserted on the built bytes by check-binary-composition.sh A6), so it fails +# to start on Debian 11, RHEL/Rocky 8, Ubuntu 20.04, Amazon Linux 2, etc. All +# linux install + self-update paths therefore use the fully-static "-portable" +# asset. This runs a given linux binary inside an old-glibc container +# (debian:bullseye, glibc 2.31) and asserts it starts: +# - RED for the dynamic standard binary (GLIBC_2.3x not found) # - GREEN for the static -portable binary (runs anywhere) # # Usage: check-glibc-compat.sh diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 993870217..e724843a9 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1256,8 +1256,9 @@ fi echo "OK: update hands off to $UPDATE_SCRIPT without downloading" # The glibc constraint did NOT disappear with in-process update -- it moved. The -# standard linux asset dynamically links glibc 2.38+ and breaks on Debian 11, -# RHEL 8 and Ubuntu 20.04, so the installer must fetch the static "-portable" +# standard linux asset is built on Ubuntu 22.04, dynamically links glibc 2.35+ +# and breaks on Debian 11, RHEL 8 and Ubuntu 20.04, so the installer must fetch +# the static "-portable" # build. Guard it where the behaviour now lives instead of retiring the # protection along with the code that used to implement it. if [ -f "$REPO_ROOT/install.sh" ]; then diff --git a/src/cli/cli.c b/src/cli/cli.c index 587e25918..366cc4cff 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -14358,9 +14358,9 @@ static void build_update_url(char *url, int url_sz, const char *os, const char * base_url = "https://github.com/DeusData/codebase-memory-mcp/releases/latest/download"; } /* Linux ships a fully-static "-portable" build; the standard linux binary - * dynamically links glibc 2.38+ and fails on older distros. macOS/Windows - * have no such variant. Keep in sync with install.sh / install.js / pypi - * _cli.py. */ + * is built on Ubuntu 22.04, dynamically links glibc 2.35+ and fails on + * older distros. macOS/Windows have no such variant. Keep in sync with + * install.sh / install.js / pypi _cli.py. */ const char *portable = (strcmp(os, "linux") == 0) ? "-portable" : ""; snprintf(url, url_sz, "%s/codebase-memory-mcp-%s-%s%s.%s", base_url, os, arch, portable, ext); } diff --git a/test-infrastructure/Dockerfile.glibc22 b/test-infrastructure/Dockerfile.glibc22 index 094645323..cb65a6e14 100644 --- a/test-infrastructure/Dockerfile.glibc22 +++ b/test-infrastructure/Dockerfile.glibc22 @@ -3,9 +3,11 @@ # Mirrors CI's broad-matrix ubuntu-22.04 legs: the OLDEST userland we claim # the portable (musl-static) binary runs on. Two assertions live here: # 1. the portable binary passes the full canonical smoke on glibc 2.35; -# 2. the DYNAMIC binary refuses to start (its glibc 2.38+ floor is by -# design — old-glibc coverage is the portable binary's job, and a -# dynamic binary that silently ran here would mask a floor regression). +# 2. the LOCAL dynamic binary (built in the noble test image, glibc 2.39) +# refuses to start — proof that this venue really is an older userland +# than the build image. The RELEASE dynamic binary is built in a pinned +# ubuntu:22.04 container instead and does start on 2.35; its floor is +# asserted on the built bytes by check-binary-composition.sh (A6). # Pinned by digest like every other venue image: a floating tag means the # glibc-FLOOR assertion silently drifts to whatever ubuntu:22.04 points at, and # a floor that moves is not a floor. (Scorecard PinnedDependenciesID #76 — this diff --git a/test-infrastructure/docker-compose.yml b/test-infrastructure/docker-compose.yml index f58c78c82..f6e3950c6 100644 --- a/test-infrastructure/docker-compose.yml +++ b/test-infrastructure/docker-compose.yml @@ -286,7 +286,10 @@ services: # ── glibc floor (ubuntu-22.04, glibc 2.35) ───────────────── # Mirrors CI's broad-matrix 22.04 legs: the portable (musl-static) binary # must pass the canonical smoke on the oldest supported userland, and the - # dynamic binary must REFUSE to start (glibc 2.38+ floor is by design). + # LOCAL dynamic binary (built in the noble test image) must REFUSE to start, + # proving this venue is older than the build image. The release dynamic + # binary builds in a pinned ubuntu:22.04 container instead (2.35 floor, + # asserted by check-binary-composition.sh A6). smoke-glibc-floor: build: context: .. @@ -301,9 +304,9 @@ services: file build/alpine/codebase-memory-mcp | grep -q "statically linked" echo "=== glibc-floor: portable binary smoke on glibc 2.35 ===" scripts/smoke-local.sh ./build/alpine/codebase-memory-mcp - echo "=== glibc-floor: dynamic binary must refuse (glibc 2.38+ floor) ===" + echo "=== glibc-floor: local noble-built dynamic binary must refuse ===" if build/linux-arm64/codebase-memory-mcp --version 2>/tmp/dynamic-floor.err; then - echo "ERROR: dynamic binary ran on glibc 2.35 — floor contract broken" + echo "ERROR: a noble-built dynamic binary ran on this venue — it is not older than the build image" exit 1 fi grep -qi 'GLIBC' /tmp/dynamic-floor.err From dd4b5491c60194b72f53823538c5c93a1be6a119 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 18:22:27 +0100 Subject: [PATCH 4/5] ci: make the glibc-floor PR leg a gating lane The glibc-floor job builds the production binary in the pinned ubuntu:22.04 release container and holds it to the release glibc floor (2.35 / GLIBCXX 3.4.30). It ran report-only; it now gates like every other lane. - select-lanes.sh: new lane glibc-floor, part of the full set and selected whenever smoke-ubuntu is (a FOLLOWS rule), i.e. whenever the production Linux binary can change. Docs-only and tests-only changes do not select it. - pr.yml: the glibc-floor job runs when its own lane is selected, and ci-ok needs it. - require-all-green.sh: maps the glibc-floor job to its lane, so a selected glibc-floor that fails, is skipped or cancelled, or never runs fails ci-ok; when it is not selected it may skip. - Contracts: the selector table and the FULL set carry the lane, and every selector case asserts glibc-floor is selected exactly when smoke-ubuntu is; the lane-gate contract adds green, failed, skipped, cancelled, missing and unselected cases; the lane-wiring contract requires the job to serve the lane, ci-ok to need it, and no continue-on-error on it. - scripts/ci/README.md notes the rule. Refs #2486 Signed-off-by: Martin Vogel --- .github/workflows/pr.yml | 26 +++++++++---------- scripts/ci/README.md | 2 +- scripts/ci/require-all-green.sh | 1 + scripts/ci/select-lanes.sh | 10 +++++++- tests/fixtures/select-lanes/cases.tsv | 20 +++++++-------- tests/test_lane_gate_contract.sh | 37 +++++++++++++++++++++++++-- tests/test_lane_wiring_contract.sh | 16 ++++++++---- tests/test_select_lanes.sh | 12 +++++++-- 8 files changed, 90 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2ed425a7c..fed91c4b3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -289,20 +289,20 @@ jobs: with: mode: pr - # ── glibc floor, REPORT-ONLY: builds the production binary in the SAME - # pinned ubuntu:22.04 container the standard Linux release binaries build - # in (_build.yml build-linux) and runs the composition gate with the same - # declared floor, so a change that needs a newer glibc or libstdc++ symbol - # version -- or stops compiling with that GCC 11 toolchain -- shows up at - # PR time instead of in a release run. Light on purpose: a headless build - # (the shipped composition only swaps src/ui/embedded_stub.c for the - # generated asset data) and no smoke. Deliberately NOT in ci-ok's needs - # and no lane of its own: it never blocks or passes a merge. It runs - # whenever the selection holds smoke-ubuntu, i.e. whenever the production - # binary can change. ── + # ── glibc floor (lane glibc-floor, GATING): builds the production binary in + # the SAME pinned ubuntu:22.04 container the standard Linux release + # binaries build in (_build.yml build-linux) and runs the composition gate + # with the same declared floor, so a change that needs a newer glibc or + # libstdc++ symbol version -- or stops compiling with that GCC 11 + # toolchain -- fails the PR instead of a release run. Light on purpose: a + # headless build (the shipped composition only swaps + # src/ui/embedded_stub.c for the generated asset data) and no smoke. The + # selector picks the lane whenever it picks smoke-ubuntu, i.e. whenever + # the production binary can change; ci-ok needs it, and a selected + # glibc-floor that fails, skips or never runs fails ci-ok. ── glibc-floor: needs: [changes] - if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') }} + if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'glibc-floor') }} runs-on: ubuntu-latest container: image: ubuntu:22.04@sha256:076e7bf82319619f28dd0aa9c02ab34b055ad37100395b4f49a2ffba04fb5e11 @@ -429,7 +429,7 @@ jobs: # every lane the selector chose ran green: `skipped` is OK only for a lane # nobody selected (a selected lane that skipped is a silent gate loss). # memwaste and the test-impact shadow are report-only and not needed here. - needs: [changes, security, lint, test, pr-smoke, contracts, codeql] + needs: [changes, security, lint, test, pr-smoke, contracts, codeql, glibc-floor] if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/scripts/ci/README.md b/scripts/ci/README.md index f38cf36a8..9a806ecb6 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -14,7 +14,7 @@ CI and the local infrastructure — both of which the venue-parity contract | `check-glibc-compat.sh` | Run a linux binary in debian:bullseye (glibc 2.31) — the portable binary must start on old glibc. | `_smoke.yml` portable legs | | `generate-sbom.py` | The release SPDX SBOM (vendored versions reviewable here, diffable by vendoring PRs — was inline YAML). | `release.yml` | | `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML) — and, under lane selection, every SELECTED lane ran and succeeded (skipped/cancelled/missing = red; unselected lanes may skip). | `pr.yml ci-ok` | -| `select-lanes.sh` | Decide which PR lanes a change set runs (tier, lanes, full, reasons) from its file list — the R1 rule set, fail-safe to every lane. Pinned by `tests/test_select_lanes.sh` and the September 2026 history replay (`scripts/test-impact/replay-selector.sh`). | `pr.yml changes` | +| `select-lanes.sh` | Decide which PR lanes a change set runs (tier, lanes, full, reasons) from its file list — the R1 rule set, fail-safe to every lane; `glibc-floor` rides with `smoke-ubuntu`. Pinned by `tests/test_select_lanes.sh` and the September 2026 history replay (`scripts/test-impact/replay-selector.sh`). | `pr.yml changes` | | `test-impact-shadow.sh` | The report-only test-impact shadow: fetch the latest released binary (checksum-verified) or take a given one, index this checkout into a throwaway cache, run `detect_changes` against the base and map it to suites (`scripts/test-impact/predict.py`). Never gates. | `pr.yml test-impact-shadow` | | `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). No manifests is green only when the lane selection holds no test leg. | `_test.yml` shard-completeness | | `prepare-release-candidates.sh` | Copy one linker output into stripped/unstripped candidates, finalize signatures, composition-check them without execution, and record their hashes. | `_build.yml`, local artifact smoke | diff --git a/scripts/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index 149eef2da..1758eefb3 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -70,6 +70,7 @@ if 'LANES' in os.environ: 'test / test-diag': 'diag', 'test / test-msan': 'msan', 'test / test-lsan-macos': 'lsan-macos', 'test / test-windows-guards': 'windows-guards', 'test / shard-completeness': 'shard-completeness', 'contracts': 'contracts', + 'glibc-floor': 'glibc-floor', } matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (', 'codeql / analyze (') diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index 18f72c578..f4062271e 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -320,7 +320,7 @@ def tier_of(cats): # lane, by any full run: an undeterminable diff scans every language. CODEQL_LANES = {"c-cpp": "codeql-cpp", "javascript-typescript": "codeql-js", "python": "codeql-py", "actions": "codeql-actions"} -FULL = {"diag", "license-gate", "lint", "lint-mem", "lsan-macos", +FULL = {"diag", "glibc-floor", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", @@ -330,6 +330,11 @@ LINT = {"lint", "lint-mem"} CORE_LEGS = {"unix-x86", "unix-macos14", "windows", "shard-completeness"} TEST_LEGS = {"unix-x86", "unix-arm64", "unix-macos14", "unix-macos-intel", "windows"} ALWAYS = {"security-static"} +# Lanes that ride with another lane, whatever selected it: glibc-floor (pr.yml: +# the production binary built in the pinned ubuntu:22.04 release container and +# held to the release glibc floor) runs whenever smoke-ubuntu does, i.e. +# whenever the production Linux binary can change. +FOLLOWS = {"smoke-ubuntu": "glibc-floor"} R1 = { "T0a-docs": {"contracts"}, # The contract steps police test-infrastructure/ and lint config too. @@ -474,6 +479,9 @@ def select(paths, expect=None): lanes = lanes | extra if lanes & TEST_LEGS: lanes = lanes | {"shard-completeness"} + for leader, follower in sorted(FOLLOWS.items()): + if leader in lanes: + lanes = lanes | {follower} for language, lane in sorted(CODEQL_LANES.items()): hits = [p for p in paths if codeql_scope(language, p)] if hits and lane not in lanes: diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv index 29d07ca33..3ac0b27b4 100644 --- a/tests/fixtures/select-lanes/cases.tsv +++ b/tests/fixtures/select-lanes/cases.tsv @@ -10,7 +10,7 @@ npm-readme-runs-the-test-legs pkg/npm/README.md T1-tests false lint lint-mem sec issue-template-is-docs .github/ISSUE_TEMPLATE/bug.yml T0a-docs false contracts security-static local-infra test-infrastructure/run.sh T0b-nonproduct false contracts lint lint-mem security-static lint-config scripts/memory-core-baseline.txt T0b-nonproduct false contracts lint lint-mem security-static -graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards codeql-js +graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts glibc-floor lint lint-mem security-static smoke-ubuntu windows-guards codeql-js memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false contracts lint lint-mem memwaste security-static non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 @@ -19,13 +19,13 @@ test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem securit contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows fuzz-harness tests/fuzz/fuzz_cypher.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows docs-plus-tests README.md tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows -lsp-language internal/cbm/lsp/go_lsp.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp -lsp-core internal/cbm/lsp/type_registry.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp -grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp -extraction internal/cbm/extract_calls.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp -pipeline src/pipeline/pass_calls.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp -mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp -mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp codeql-py +lsp-language internal/cbm/lsp/go_lsp.c T2-extract false glibc-floor lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +lsp-core internal/cbm/lsp/type_registry.c T2-extract false glibc-floor lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false glibc-floor lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +extraction internal/cbm/extract_calls.c T2-extract false glibc-floor lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +pipeline src/pipeline/pass_calls.c T3-mid false glibc-floor lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false glibc-floor lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false glibc-floor lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp codeql-py daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions @@ -59,7 +59,7 @@ harness-driver-forces-full scripts/test.sh T5-core true FULL harness-scheduler-forces-full scripts/run-test-wave.py T5-core true FULL lower-tier-plus-trigger-is-full docs/x.md src/pipeline/a.c tests/test_main.c T3-mid true FULL empty-list-fails-safe - T0a-docs true FULL -graph-ui-python-agent graph-ui/agents/agent.py T0b-nonproduct false codeql-py contracts lint lint-mem security-static smoke-ubuntu windows-guards -vendored-js-is-not-scanned graph-ui/vendor/lib.js T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards +graph-ui-python-agent graph-ui/agents/agent.py T0b-nonproduct false codeql-py contracts glibc-floor lint lint-mem security-static smoke-ubuntu windows-guards +vendored-js-is-not-scanned graph-ui/vendor/lib.js T0b-nonproduct false contracts glibc-floor lint lint-mem security-static smoke-ubuntu windows-guards vendored-only-c-scans-c vendored/sqlite3/sqlite3.c T5-core true FULL codeql-config-is-full .github/codeql/codeql-config.yml T5-core true FULL diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index f016f2e99..9078780d5 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -33,9 +33,14 @@ trap 'rm -rf "$WORK"' EXIT failures=0 # ── ci-ok ──────────────────────────────────────────────────────────────── -NEEDS_OK='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"success"},"test":{"result":"success"},"pr-smoke":{"result":"skipped"},"contracts":{"result":"skipped"}}' -NEEDS_RED='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"failure"},"test":{"result":"skipped"},"pr-smoke":{"result":"skipped"},"contracts":{"result":"skipped"}}' +NEEDS_OK='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"success"},"test":{"result":"success"},"pr-smoke":{"result":"skipped"},"contracts":{"result":"skipped"},"glibc-floor":{"result":"skipped"}}' +NEEDS_RED='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"failure"},"test":{"result":"skipped"},"pr-smoke":{"result":"skipped"},"contracts":{"result":"skipped"},"glibc-floor":{"result":"skipped"}}' +NEEDS_T3='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"success"},"test":{"result":"success"},"pr-smoke":{"result":"success"},"contracts":{"result":"skipped"},"glibc-floor":{"result":"success"}}' +NEEDS_FLOOR_RED='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"success"},"test":{"result":"success"},"pr-smoke":{"result":"success"},"contracts":{"result":"skipped"},"glibc-floor":{"result":"failure"}}' T1='["lint","lint-mem","security-static","shard-completeness","unix-macos14","unix-x86","windows"]' +# A product change: the Ubuntu smoke leg and, riding with it, the gating +# release-container build (glibc-floor). +T3='["glibc-floor","lint","lint-mem","security-static","shard-completeness","smoke-ubuntu","unix-macos14","unix-x86","windows"]' job() { # name conclusion [status] printf '{"name":"%s","status":"%s","conclusion":%s}\n' "$1" "${3:-completed}" \ @@ -60,10 +65,16 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg job "test / shard-completeness" success job "pr-smoke" skipped job "contracts" skipped + job "glibc-floor" skipped job "memwaste" skipped job "test-impact-shadow" failure job "ci-ok" null in_progress } +t3_jobs() { # a green T3 run's jobs; $1 = glibc-floor's conclusion, or "absent" + t1_jobs success | grep -v -e '"name":"pr-smoke"' -e '"name":"glibc-floor"' + job "pr-smoke (ubuntu-latest, smoke-ubuntu)" success + [ "$1" = absent ] || job "glibc-floor" "$1" +} gate() { # description want needs lanes jobs-file|- local description="$1" want="$2" needs="$3" lanes="$4" jobs="$5" got @@ -109,6 +120,28 @@ gate "an unknown lane is red" 1 "$NEEDS_OK" '["lint","security-static","no-such- gate "a malformed selection is red" 1 "$NEEDS_OK" 'lint,unix-x86' "$WORK/t1-green" gate "a selection without the job list is red" 1 "$NEEDS_OK" "$T1" "$WORK/does-not-exist" +# glibc-floor GATES (user decision R103, #2887): selected with smoke-ubuntu, it +# must run and succeed like any lane; unselected (docs, tests) it may skip. +t3_jobs success >"$WORK/t3-green" +t3_jobs failure >"$WORK/t3-floor-failed" +t3_jobs skipped >"$WORK/t3-floor-skipped" +t3_jobs cancelled >"$WORK/t3-floor-cancelled" +t3_jobs absent >"$WORK/t3-floor-absent" +{ + job "changes" success + job "security / security-static" success + job "contracts" success + job "glibc-floor" skipped + job "ci-ok" null in_progress +} >"$WORK/docs-floor-skipped" +gate "glibc-floor selected and green" 0 "$NEEDS_T3" "$T3" "$WORK/t3-green" +gate "a selected glibc-floor that FAILED is red" 1 "$NEEDS_T3" "$T3" "$WORK/t3-floor-failed" +gate "a selected glibc-floor that was skipped is red" 1 "$NEEDS_T3" "$T3" "$WORK/t3-floor-skipped" +gate "a selected glibc-floor that was cancelled is red" 1 "$NEEDS_T3" "$T3" "$WORK/t3-floor-cancelled" +gate "a selected glibc-floor with no job at all is red" 1 "$NEEDS_T3" "$T3" "$WORK/t3-floor-absent" +gate "an unselected glibc-floor (docs-only) may skip" 0 "$NEEDS_OK" '["contracts","security-static"]' "$WORK/docs-floor-skipped" +gate "a failed glibc-floor fails the needs-level gate" 1 "$NEEDS_FLOOR_RED" - - + # Matrix jobs carry their lane in the name; the neighbouring single job # (test-windows-guards) must never be read as the windows leg. { diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index bd49dc2f7..56763f3a1 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -8,8 +8,9 @@ # before lane selection. Only pr.yml asks for the PR shard profile. # 2. pr.yml feeds the selector's lanes to every reusable workflow, runs the # T0a/T0b contracts job, and hands ci-ok both the selection and the -# run's job list (actions: read) -- ci-ok needs `contracts` but never the -# report-only memwaste / shadow jobs, and the shadow is continue-on-error. +# run's job list (actions: read) -- ci-ok needs `contracts` and the +# gating `glibc-floor` but never the report-only memwaste / shadow jobs, +# and the shadow is continue-on-error. # 3. every lane of scripts/ci/select-lanes.sh --list-lanes is served by a # job: matrix lanes as a "lane":"" tag in the leg data (the tag # ci-ok reads back from the job name), single lanes by an `if:` that @@ -102,10 +103,14 @@ require("scripts/test.sh --contracts-only" in pr.get("contracts", ""), ci_ok = pr.get("ci-ok", "") needs = re.search(r"needs: \[([^\]]*)\]", ci_ok) needs = {n.strip() for n in needs.group(1).split(",")} if needs else set() -require({"changes", "security", "lint", "test", "pr-smoke", "contracts"} <= needs, - f"ci-ok must need every gating stage incl. contracts, has {sorted(needs)}") +require({"changes", "security", "lint", "test", "pr-smoke", "contracts", "glibc-floor"} <= needs, + f"ci-ok must need every gating stage incl. contracts and glibc-floor, has {sorted(needs)}") require(not needs & {"memwaste", "test-impact-shadow"}, "ci-ok must not need the report-only memwaste / shadow jobs") +# glibc-floor gates (user decision R103, #2887): no continue-on-error may turn +# the release-container build back into a report. +require("continue-on-error" not in pr.get("glibc-floor", "continue-on-error"), + "pr.yml glibc-floor must exist and gate: no continue-on-error") for needle, why in (("LANES: ${{ needs.changes.outputs.lanes }}", "the selection"), ("JOBS: ", "the run's job list"), ("actions: read", "actions: read to list jobs"), @@ -164,7 +169,8 @@ SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"), "diag": ("_test.yml", "test-diag"), "msan": ("_test.yml", "test-msan"), "lsan-macos": ("_test.yml", "test-lsan-macos"), "windows-guards": ("_test.yml", "test-windows-guards"), - "memwaste": ("pr.yml", "memwaste"), "contracts": ("pr.yml", "contracts")} + "memwaste": ("pr.yml", "memwaste"), "contracts": ("pr.yml", "contracts"), + "glibc-floor": ("pr.yml", "glibc-floor")} ALWAYS = {"security-static": ("_security.yml", "security-static"), "shard-completeness": ("_test.yml", "shard-completeness")} for lane in lanes: diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index 4c8183fdc..19b8b8f44 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -10,7 +10,9 @@ # - determinism: order and duplicates never change a byte of the output; # - one decision per input channel: stdin, a file, --batch and the # github-output form all agree; -# - a truncated file list (the pulls/files API stops at 3000) selects FULL. +# - a truncated file list (the pulls/files API stops at 3000) selects FULL; +# - glibc-floor (the gating release-container build, pr.yml) is selected +# exactly when smoke-ubuntu is, in every case. # The history replay (every September PR push + the real failures) is # scripts/test-impact/replay-selector.sh, a separate step. # @@ -48,7 +50,7 @@ failures = [] # Every lane of the full PR pipeline, spelled out here rather than read from # the selector, so a lane silently dropped from the selector's universe fails. FULL = { - "diag", "license-gate", "lint", "lint-mem", "lsan-macos", + "diag", "glibc-floor", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", @@ -106,6 +108,12 @@ for name, files, tier, full, lanes in cases: failures.append(f"{name}: lanes/reasons are not sorted") if full and not any(r.startswith("full:") for r in got["reasons"]): failures.append(f"{name}: full=true without a 'full:' reason") + # The release-container build gates whenever the production Linux binary + # can change -- the same condition that selects the Ubuntu smoke leg. + if ("glibc-floor" in got["lanes"]) != ("smoke-ubuntu" in got["lanes"]): + failures.append(f"{name}: glibc-floor must be selected exactly when smoke-ubuntu is, " + f"got glibc-floor={'glibc-floor' in got['lanes']} " + f"smoke-ubuntu={'smoke-ubuntu' in got['lanes']}") # 2. Determinism: shuffled, duplicated, CRLF-terminated input with blank lines # is byte-identical to the canonical run. From f7045ac6b36133004a359be54b345d789c4de63a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 18:22:27 +0100 Subject: [PATCH 5/5] ci: run the dynamic Linux binary on plain ubuntu:22.04 in the release smoke The composition gate asserts the glibc 2.35 floor on the built bytes, but nothing ran those bytes on a 22.04 userland before a release. - scripts/ci/check-glibc-floor-runtime.sh (new canonical entry): runs a linux binary in a plain container of a digest-pinned image with nothing installed and requires --version, cli index_repository on a tiny C and Python fixture (status indexed) and cli list_projects (lists the fixture) to succeed. A binary built on a newer userland fails at --version, with the loader errors in the log. - _smoke.yml smoke-unix: a gating step runs it on every Linux leg (amd64 and arm64, each on its own artifact) with the same ubuntu:22.04 digest that build-linux builds in. - test_venue_parity_contract.sh: the step must exist once on smoke-unix, run for every Linux leg, gate (no continue-on-error) and pin the same digest as build-linux and the PR glibc-floor leg; the core smoke-unix matrix must hold linux amd64 and arm64; the new entry joins the --help and strict-flag interface probes. - scripts/ci/README.md lists the new entry. Refs #2486 Signed-off-by: Martin Vogel --- .github/workflows/_smoke.yml | 16 ++- scripts/ci/README.md | 1 + scripts/ci/check-glibc-floor-runtime.sh | 126 ++++++++++++++++++++++++ tests/test_venue_parity_contract.sh | 47 ++++++++- 4 files changed, 184 insertions(+), 6 deletions(-) create mode 100755 scripts/ci/check-glibc-floor-runtime.sh diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 4c26391a0..f5c49a257 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -43,7 +43,9 @@ jobs: # # NOTE: the *dynamic* linux binary is built in a pinned Ubuntu 22.04 # container (_build.yml build-linux) and links glibc 2.35+; the - # composition gate (A6) asserts that floor on the built bytes, so no + # composition gate (A6) asserts that floor on the built bytes and + # each core Linux leg runs the binary in a plain container of that + # image (smoke-unix "Runtime start on the glibc floor"), so no # broad leg re-runs it on a 22.04 runner. Older-glibc coverage stays # the -portable (static) binary's job, exercised green by the # smoke-linux-portable broad legs (ubuntu-22.04 / 22.04-arm) and the @@ -129,6 +131,18 @@ jobs: SMOKE_REQUIRE_UI: "1" SMOKE_REQUIRE_WRAPPERS: "1" + # The standard (dynamic) Linux binary must START and INDEX on the oldest + # userland it claims (#2486): a plain container of the SAME pinned + # ubuntu:22.04 image _build.yml build-linux builds in, nothing installed. + # A6 asserts the floor on the bytes; this runs them, on both arches (each + # Linux leg checks its own artifact). The venue-parity contract holds + # this digest equal to build-linux's. + - name: Runtime start on the glibc floor (plain ubuntu:22.04) + if: matrix.goos == 'linux' + env: + CBM_FLOOR_IMAGE: ubuntu:22.04@sha256:076e7bf82319619f28dd0aa9c02ab34b055ad37100395b4f49a2ffba04fb5e11 + run: scripts/ci/check-glibc-floor-runtime.sh --image "$CBM_FLOOR_IMAGE" "$RUNNER_TEMP/cbm-artifact/codebase-memory-mcp" + - name: Security audits run: | BIN="$RUNNER_TEMP/cbm-artifact/codebase-memory-mcp" diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 9a806ecb6..f4293c03a 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -22,3 +22,4 @@ CI and the local infrastructure — both of which the venue-parity contract | `select-release-candidates.py` | Apply the reviewed tuple-local VT truth table, or the explicit dry-run stripped default, and atomically copy one content-bound binary per target. | `_build.yml` | | `verify-release-selection.py` | Recompute the selection policy and prove every executable member in all 14 public containers equals its selected SHA-256. | `_build.yml`, `release.yml` final draft verification | | `check-virustotal.sh` | Poll and validate the exact candidate scan set, enforce engine coverage and the narrow documented Microsoft `!ml` policy, and emit content-bound results evidence. | `_build.yml` | +| `check-glibc-floor-runtime.sh` | Run the standard (dynamic) linux binary in a plain container of the pinned ubuntu:22.04 floor image (digest required, nothing installed): `--version`, `cli index_repository` on a tiny fixture and `cli list_projects` must all succeed. | `_smoke.yml` smoke-unix Linux legs | diff --git a/scripts/ci/check-glibc-floor-runtime.sh b/scripts/ci/check-glibc-floor-runtime.sh new file mode 100755 index 000000000..72c377c10 --- /dev/null +++ b/scripts/ci/check-glibc-floor-runtime.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# check-glibc-floor-runtime.sh -- the standard (dynamic) Linux binary must START +# and INDEX on the oldest userland it claims. +# +# The standard Linux release binaries build in a pinned ubuntu:22.04 container +# (_build.yml build-linux) and check-binary-composition.sh A6 asserts their +# glibc / libstdc++ floor on the built bytes. This RUNS those bytes in a plain +# container of that same pinned image with nothing installed (only the libc, +# libstdc++, libgcc and zlib a stock ubuntu:22.04 ships) and requires: +# 1. --version exits 0: the loader resolved every symbol version; +# 2. cli index_repository on a tiny C + Python fixture reports "indexed"; +# 3. cli list_projects lists that project. +# A binary built on a newer userland fails step 1, and the loader's +# "version `GLIBC_2.xx' not found" lines are in the output. +# +# Usage: scripts/ci/check-glibc-floor-runtime.sh --image IMAGE [--platform P] BINARY +# --image IMAGE the floor image, pinned by digest (ubuntu:22.04@sha256:...). +# The release smoke passes the digest build-linux builds in; +# the venue-parity contract asserts it is the same one. +# --platform P docker --platform (e.g. linux/amd64) for a binary that is +# not the host's architecture; default: the host's. +# BINARY the linux binary to run (mounted read-only). +# Exit: 0 = starts and indexes on the floor; 1 = it does not; 2 = usage error. +# Needs: docker. +set -euo pipefail + +usage() { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; } + +IMAGE="" +PLATFORM="" +BIN="" +while [ $# -gt 0 ]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --image) + IMAGE="${2:-}" + [ $# -gt 1 ] && shift + ;; + --image=*) IMAGE="${1#--image=}" ;; + --platform) + PLATFORM="${2:-}" + [ $# -gt 1 ] && shift + ;; + --platform=*) PLATFORM="${1#--platform=}" ;; + -*) + echo "check-glibc-floor-runtime.sh: unknown option '$1'. Please consult --help." >&2 + exit 2 + ;; + *) + if [ -n "$BIN" ]; then + echo "check-glibc-floor-runtime.sh: more than one binary. Please consult --help." >&2 + exit 2 + fi + BIN="$1" + ;; + esac + shift +done +case "$IMAGE" in +*@sha256:*) ;; +*) + echo "check-glibc-floor-runtime.sh: --image must be pinned by digest (name@sha256:...). Please consult --help." >&2 + exit 2 + ;; +esac +if [ -z "$BIN" ] || [ ! -f "$BIN" ]; then + echo "check-glibc-floor-runtime.sh: no such binary '$BIN'. Please consult --help." >&2 + exit 2 +fi +BIN_ABS="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")" + +# Runs INSIDE the floor container under its own /bin/sh. The fixture is +# written there too, so the only host path the container sees is the binary. +# shellcheck disable=SC2016 # expanded by the container's shell, not here +PROGRAM=' +set -eu +BIN=/opt/cbm/codebase-memory-mcp +. /etc/os-release +echo "floor userland: $PRETTY_NAME, $(uname -m), $(ldd --version | head -n 1)" +echo "--- libraries the loader resolves" +ldd "$BIN" || true +echo "--- $BIN --version" +"$BIN" --version +mkdir -p /tmp/fixture/src /tmp/home +printf "%s\n" "static int helper(int x) { return x + 1; }" \ + "int run(int x) { return helper(x) * 2; }" \ + "int main(void) { return run(1) == 4 ? 0 : 1; }" >/tmp/fixture/src/main.c +printf "%s\n" "def greet(name):" " return \"hi \" + name" "" \ + "def main():" " print(greet(\"x\"))" >/tmp/fixture/src/app.py +export HOME=/tmp/home +echo "--- cli index_repository --repo-path /tmp/fixture" +INDEXED=$("$BIN" cli index_repository --repo-path /tmp/fixture) +printf "%s\n" "$INDEXED" +case "$INDEXED" in +*"\"status\":\"indexed\""*) ;; +*) + echo "FAIL: index_repository did not report status indexed" + exit 1 + ;; +esac +echo "--- cli list_projects" +PROJECTS=$("$BIN" cli list_projects) +printf "%s\n" "$PROJECTS" +case "$PROJECTS" in +*tmp-fixture*) ;; +*) + echo "FAIL: list_projects does not list the indexed fixture (tmp-fixture)" + exit 1 + ;; +esac +' + +set -- run --rm +[ -z "$PLATFORM" ] || set -- "$@" --platform "$PLATFORM" +set -- "$@" -v "$BIN_ABS:/opt/cbm/codebase-memory-mcp:ro" "$IMAGE" sh -c "$PROGRAM" + +echo "==> $(basename "$BIN") on the glibc floor: plain $IMAGE, nothing installed" +if docker "$@"; then + echo "PASS: starts, indexes and lists on the floor userland ($IMAGE)" +else + echo "FAIL: $(basename "$BIN") does not run on the floor userland ($IMAGE)" >&2 + exit 1 +fi diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index 27e5a7b10..f0d79e2b1 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -264,9 +264,10 @@ for name, pattern, why in REQUIRED: # glibc floor (#2486; contract shape from #2573): the standard Linux release # binaries, amd64 AND arm64, build inside ONE ubuntu:22.04 image pinned by # digest; the composition gate is told that image's floor; the PR leg builds in -# the same image with the same floor. A tag, a second digest, a Linux leg back -# on a hosted runner's userland or a dropped floor lets the floor drift with no -# other check noticing until a user's loader refuses the binary. +# the same image with the same floor, and the release smoke runs the built +# binary in a plain container of that image. A tag, a second digest, a Linux +# leg back on a hosted runner's userland or a dropped floor lets the floor +# drift with no other check noticing until a user's loader refuses the binary. def without_comments(text: str) -> str: return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")) @@ -298,9 +299,43 @@ if (workflows / "_build.yml").exists() and (workflows / "pr.yml").exists(): "prepare-release-candidates.sh" not in body: failures.append(f"{leg}: must run the composition gate on the " f"container-built binary") + # The release smoke RUNS the standard Linux binary on that floor (user + # decision R105, #2887): every core smoke-unix Linux leg, amd64 AND arm64, + # starts and indexes its own artifact in a plain container of the SAME + # digest, in a step that gates like its siblings. + smoke_path = workflows / "_smoke.yml" + smoke_text = without_comments(smoke_path.read_text()) if smoke_path.exists() else "" + smoke_unix = job_body(smoke_text, "smoke-unix") + runtime = [step for step in re.split(r"(?m)^(?= - )", smoke_unix) + if "scripts/ci/check-glibc-floor-runtime.sh" in step] + leg = "_smoke.yml smoke-unix runtime start check" + if len(runtime) != 1: + failures.append(f"{leg}: smoke-unix must run scripts/ci/check-glibc-floor-runtime.sh " + f"in exactly one step, found {len(runtime)}") + else: + step = runtime[0] + if not re.search(r"(?m)^ if:\s*matrix\.goos == 'linux'\s*$", step): + failures.append(f"{leg}: must run on every Linux leg, amd64 and arm64 alike " + "(`if: matrix.goos == 'linux'`)") + if "continue-on-error" in step or \ + re.search(r"(?m)^ continue-on-error:", smoke_unix): + failures.append(f"{leg}: must gate the release smoke (no continue-on-error)") + images = set(re.findall(rf"({FLOOR_IMAGE})(?![0-9a-f])", step)) + if len(images) != 1 or "--image" not in step: + failures.append(f"{leg}: must pass --image with the floor image pinned by " + f"digest (ubuntu:22.04@sha256:<64 hex>), found {sorted(images)}") + else: + floor_images |= images + core_unix = re.search(r"(?ms)CORE_UNIX='\[(.*?)\]'", smoke_text) + linux_arches = sorted(re.findall(r'"goos":"linux","goarch":"(\w+)"', + core_unix.group(1) if core_unix else "")) + if linux_arches != ["amd64", "arm64"]: + failures.append("_smoke.yml setup-matrix: the core smoke-unix legs must cover linux " + f"amd64 and arm64 (each runs the floor runtime check), found {linux_arches}") if len(floor_images) > 1: - failures.append("_build.yml build-linux and pr.yml glibc-floor must pin the SAME " - "ubuntu:22.04 digest: " + ", ".join(sorted(floor_images))) + failures.append("_build.yml build-linux, pr.yml glibc-floor and the _smoke.yml runtime " + "start check must pin the SAME ubuntu:22.04 digest: " + + ", ".join(sorted(floor_images))) arches = sorted(re.findall(r"(?m)^ - arch:\s*(\S+)\s*$", build_linux)) if arches != ["amd64", "arm64"]: failures.append("_build.yml build-linux: must build exactly the amd64 and arm64 " @@ -449,6 +484,7 @@ scripts/ci/test-impact-shadow.sh scripts/ci/generate-sbom.py scripts/package-release.sh scripts/ci/smoke-artifact.sh +scripts/ci/check-glibc-floor-runtime.sh scripts/fuzz.sh scripts/memwaste.sh test-infrastructure/run.sh @@ -493,6 +529,7 @@ scripts/fuzz.sh scripts/memwaste.sh scripts/ci/select-lanes.sh scripts/ci/test-impact-shadow.sh +scripts/ci/check-glibc-floor-runtime.sh " for entry in $STRICT_ENTRIES; do out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$?