diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 46a2c16de..9b5f5c196 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/_smoke.yml b/.github/workflows/_smoke.yml index cfe022a6a..f5417029c 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -41,12 +41,15 @@ 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 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 + # 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. @@ -128,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" @@ -329,8 +344,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/.github/workflows/pr.yml b/.github/workflows/pr.yml index 30b36e2cb..50e5962ca 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -289,6 +289,41 @@ jobs: with: mode: pr + # ── 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), 'glibc-floor') }} + 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 @@ -394,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/install.sh b/install.sh index f3666e5d4..ccb6aea0d 100755 --- a/install.sh +++ b/install.sh @@ -175,9 +175,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/README.md b/scripts/ci/README.md index f38cf36a8..f4293c03a 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 | @@ -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-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 < 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/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/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 22f6ae7a1..536fff6ba 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -14366,9 +14366,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 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_release_candidate_derivation_contract.sh b/tests/test_release_candidate_derivation_contract.sh index 9dc00cf05..0e9e5b407 100755 --- a/tests/test_release_candidate_derivation_contract.sh +++ b/tests/test_release_candidate_derivation_contract.sh @@ -172,4 +172,48 @@ fi exit 1 } -echo "PASS: native stripped/unstripped candidates derive from one linker output and publish atomically" +# A6 glibc floor (check-binary-composition.sh), on the STRIPPED candidate: the +# loader's version-needs table must survive the strongest strip. A malformed +# floor is a usage error everywhere. On glibc Linux the stub requires some +# GLIBC_2.x, so no glibc predates floor 1.0 (must FAIL) and none reaches 999.0 +# (must PASS; the C stub needs no GLIBCXX_*). Mach-O / PE report n/a. A +# non-glibc Linux (musl) cannot prove a glibc floor and must fail closed. +GATE="$ROOT/scripts/ci/check-binary-composition.sh" +FLOOR_OUT="$FIX/floor.out" +if CBM_GLIBC_FLOOR=2.x bash "$GATE" "$STRIPPED" >"$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" 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_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. diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index 8b4b8b639..f0d79e2b1 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -261,6 +261,89 @@ 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, 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("#")) + +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") + # 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, 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 " + 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 @@ -401,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 @@ -445,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=$?