build: produce every Linux artifact in a pinned container and check its floor

The released .deb installs cleanly on Debian 12 and Ubuntu 22.04 and the
daemon then cannot start, with the loader reporting GLIBC_2.39 not found.
Three binaries are affected, fips, fipstop and fips-gateway; fipsctl runs,
which is why it stayed quiet, since an install checked by running fipsctl
gets a clean answer while the daemon is dead. It is not a v0.5.0 regression:
every release artifact from v0.3.0 onward carries the same floor and the same
unversioned dependency.

The cause is the build machine. Rust's standard library references
pidfd_spawnp and pidfd_getpid as weak undefined symbols behind a runtime
check, so a binary should fall back where the C library lacks them. Linking
against a C library that has them records a hard version dependency instead,
and the loader refuses the image on that entry alone. No Rust changed here.

One script now produces the Linux artifacts. It builds in a container pinned
to the oldest distribution still supported for free by its distributor, named
with the floor in packaging/build-floor.env, and runs the floor check on the
package it produced, so every producer is gated rather than one workflow. The
floor guard reads readelf's Version needs section: the obvious objdump
formulation returns 2.2.5 for the shipped fips and would have passed every
affected release.

The script prints the package path as the only thing on its stdout, which is
what lets a caller take it without parsing, and it takes --features. Both
required care. The container's own stdout reaches the caller, so the build
runs with its output on stderr; without that, a caller using a plain command
substitution captures four lines of build chatter along with the path. And a
feature build must keep the +<features> marker that distinguishes it from the
default build of the same commit, or dpkg sees two packages at one version and
a revert silently no-ops. The version is derived on the host, because the
image has no git and the source is mounted read-only, so build-deb.sh now
applies that marker to an explicit version as well as to one it derives.

Both runners now build once through that script and install the artifact.
The five deb-install legs previously built their own package each, so one CI
run performed five complete release builds and four were waste; they now live
in a job of their own that downloads one built package, which also stops the
rest of the integration matrix waiting on it. The parity guard read one
hardcoded job and now sweeps every job's matrix. The release workflow builds
both architectures through the same script, and the systemd tarball takes its
binaries out of that package instead of from a second, unchecked set on the
runner, then is floor-checked after the strip.

Cargo.toml derives the dependency with $auto rather than stating a bare libc6
that nothing can fail. Note the ordering this implies for any pipeline that
builds on a current distribution: until it builds through this script, its
packages will declare libc6 (>= 2.39).

Measured: the container build produces four binaries at 2.34, and one artifact
passes all five distributions, 95 checks, in about two minutes. The floor
check fails the released 0.5.0 package on three binaries and passes this one.
A profiling build produces fips_0.5.1~dev+git<date>.<sha>+profiling-1_amd64.deb.
This commit is contained in:
Johnathan Corgan
2026-09-05 23:34:05 +00:00
parent 25daa4de1a
commit 867f5f81b4
16 changed files with 807 additions and 144 deletions
+15
View File
@@ -0,0 +1,15 @@
# Keep the build context small.
#
# Several test harnesses build images with the repository root as the context
# (testing/deb-install/test.sh and testing/dns-resolver/test.sh among them), and
# without this every one of them uploads the whole Cargo target directory to the
# daemon before running a build that does not use a single file from it. On a
# developer's machine that directory reaches double-digit gigabytes.
#
# Deliberately narrow. Nothing here excludes testing/**/.cache, which
# testing/deb-install/test.sh copies a package out of, and no Dockerfile in the
# tree copies from target/ on the host: examples/k8s-sidecar/Dockerfile builds
# its own inside the image with a multi-stage COPY --from.
target/
.git/
deploy/
+112 -54
View File
@@ -27,7 +27,8 @@ env:
# ─────────────────────────────────────────────────────────────────────────────
# CI parity invariant
#
# This GitHub integration matrix and the local default suite set
# This workflow's integration matrices — the `integration:` job and the
# `deb-install:` job — and the local default suite set
# (testing/ci-local.sh) MUST run the same integration suites, EXCEPT for the
# deliberate local-only entries below. Adding a suite to one runner without
# the other means "local green" and "GitHub green" stop being equivalent.
@@ -504,29 +505,6 @@ jobs:
# recovers from delay, and never panics.
- suite: stun-faults
type: stun-faults
# ── Real-deb install across target distros ─────────────────────
# Boots a privileged systemd container per distro, runs
# `apt install ./fips_*.deb` with the locally-built package,
# then asserts end-to-end `.fips` resolution + the
# gateway/daemon default-pairing. The most thorough single
# test surface — exercises packaging, maintainer scripts,
# systemd unit ordering, real TUN, and the DNS responder
# filter on a per-distro resolver backend.
- suite: deb-install-debian12
type: deb-install
scenario: debian12
- suite: deb-install-debian13
type: deb-install
scenario: debian13
- suite: deb-install-ubuntu22
type: deb-install
scenario: ubuntu22
- suite: deb-install-ubuntu24
type: deb-install
scenario: ubuntu24
- suite: deb-install-ubuntu26
type: deb-install
scenario: ubuntu26
# ── DNS resolver multi-backend coverage ────────────────────────
# Exercises every fips-dns-setup backend (resolved, dnsmasq,
# NM+dnsmasq, dns-delegate, no-resolver) across five distros,
@@ -734,36 +712,6 @@ jobs:
docker compose -f testing/static/docker-compose.yml \
--profile gateway down --volumes --remove-orphans
# ── Real-deb install integration ────────────────────────────────────
# The deb-install harness builds its own .deb from source in a
# cargo-deb builder image; the pre-built Linux binary from the
# build job is intentionally not used here so the test exercises
# the full packaging pipeline. ~5-7 min cold-cache on a fresh
# runner (.deb build dominates), ~1-2 min warm-cache.
- name: Run deb-install scenario
if: matrix.type == 'deb-install'
timeout-minutes: 25
run: bash testing/deb-install/test.sh ${{ matrix.scenario }}
- name: Collect logs on failure (deb-install)
if: matrix.type == 'deb-install' && failure()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
echo "--- ${c} fips.service ---"
docker exec "$c" journalctl -u fips.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-dns.service ---"
docker exec "$c" journalctl -u fips-dns.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-gateway.service ---"
docker exec "$c" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -100 || true
done
- name: Stop containers (deb-install)
if: matrix.type == 'deb-install' && always()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
docker rm -f "$c" >/dev/null 2>&1 || true
done
# ── Native datagram API ─────────────────────────────────────────────
# Reads FIPS_TEST_IMAGE rather than defaulting to a name, so it runs
# against the image this workflow built. The two-node check creates and
@@ -817,3 +765,113 @@ jobs:
docker ps -a --filter "name=fips-dns-test-" --format '{{.Names}}' | while read -r c; do
docker rm -f "$c" >/dev/null 2>&1 || true
done
# ─────────────────────────────────────────────────────────────────────────────
# Job 4 – The .deb the install suite installs
#
# Built once, here, by the same script the release workflow and a local run
# call, so the package the suite installs is built the way the shipped one is.
# That was not true before: each install leg built its own package on a fresh
# runner with no cache, so one run performed five complete Rust release builds
# and four were waste — and none of them was built the way the release is, so
# the suite could not exhibit a defect that only the release environment
# produced.
#
# The script builds in the pinned container from packaging/build-floor.env and
# runs testing/check-glibc-floor.sh on the result, so this job is also where a
# floor violation stops the run.
# ─────────────────────────────────────────────────────────────────────────────
deb-package:
name: Build .deb
runs-on: ubuntu-latest
needs: [build, test]
if: ${{ !inputs.skip_integration }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Build the .deb in the pinned build container
timeout-minutes: 30
run: bash packaging/debian/build-deb-container.sh --output-dir deploy
- name: Upload the .deb
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: fips-deb
path: deploy/fips_*.deb
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────────────────────
# Job 5 – Real-deb install across target distros
#
# Boots a privileged systemd container per distro, runs `apt install
# ./fips_*.deb` with the package job 4 built, then asserts end-to-end `.fips`
# resolution + the gateway/daemon default-pairing. The most thorough single
# test surface — exercises packaging, maintainer scripts, systemd unit
# ordering, real TUN, and the DNS responder filter on a per-distro resolver
# backend.
#
# A job of its own rather than legs of the integration matrix: the install legs
# are the only ones that need the package, and as integration legs every other
# integration suite would wait on the package build.
#
# The legs keep `type: deb-install` and `scenario:` because
# testing/check-ci-parity.sh reads those to match this matrix against the local
# suite's distro list; the steps below use `scenario:` only.
# ─────────────────────────────────────────────────────────────────────────────
deb-install:
name: Deb install (${{ matrix.scenario }})
runs-on: ubuntu-latest
needs: [deb-package]
if: ${{ !inputs.skip_integration }}
strategy:
fail-fast: false
matrix:
include:
- type: deb-install
scenario: debian12
- type: deb-install
scenario: debian13
- type: deb-install
scenario: ubuntu22
- type: deb-install
scenario: ubuntu24
- type: deb-install
scenario: ubuntu26
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Download the .deb
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: fips-deb
path: _deb
- name: Run deb-install scenario
timeout-minutes: 25
run: |
deb=$(find _deb -maxdepth 1 -type f -name 'fips_*.deb' | sort | head -1)
[ -n "$deb" ] || { echo "no .deb in the downloaded artifact" >&2; exit 1; }
bash testing/deb-install/test.sh --deb "$deb" ${{ matrix.scenario }}
- name: Collect logs on failure
if: failure()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
echo "--- ${c} fips.service ---"
docker exec "$c" journalctl -u fips.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-dns.service ---"
docker exec "$c" journalctl -u fips-dns.service --no-pager 2>&1 | tail -100 || true
echo "--- ${c} fips-gateway.service ---"
docker exec "$c" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -100 || true
done
- name: Stop containers
if: always()
run: |
docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do
docker rm -f "$c" >/dev/null 2>&1 || true
done
+88 -33
View File
@@ -49,6 +49,11 @@ jobs:
runs-on: ${{ matrix.os }}
needs: determine-versioning
# Both legs build in the same pinned container. Nothing passes --platform,
# so the arm runner resolves the arm64 variant of the base image and builds
# natively; the floor check runs on that package too, so an aarch64 build
# above the floor fails the leg rather than shipping. What the runner
# supplies is Docker and the checkout -- neither leg compiles on the host.
strategy:
fail-fast: false
matrix:
@@ -68,32 +73,76 @@ jobs:
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm
# The host no longer compiles anything: the container carries the
# toolchain and the build dependencies. llvm is here only for llvm-strip,
# which build-tarball.sh uses on the binaries recovered from the package.
- name: Install host packaging tools
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache: false
rustflags: ''
# Build in the pinned container rather than on the runner. The runner's
# glibc is what put a GLIBC_2.39 requirement into every Linux artifact
# from v0.3.0 onward, so the package installed cleanly and then could not
# load on Debian 12 or Ubuntu 22.04. packaging/build-floor.env declares
# the base image and the floor; the script builds there and runs
# testing/check-glibc-floor.sh on the package it produced, so a build that
# would ship an unloadable binary fails here instead of at the user.
#
# This is the same script ci.yml and a local run call, so the package that
# passes the five-distro suite is built the way this one is.
- name: Build Debian package in the pinned container
id: deb
shell: bash
run: |
set -euo pipefail
: ${GITHUB_OUTPUT:=/tmp/github_output}
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-
packaging/debian/build-deb-container.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--output-dir deploy \
| tee /tmp/build-deb-container.log
- name: Install cargo-deb
run: cargo install cargo-deb --version 3.6.3 --locked
# The script prints the package path as its last line of stdout.
# Only stdout is captured; its diagnostics go to stderr and straight
# to the job log, so nothing can land after the path.
DEB_FILE=$(tail -n 1 /tmp/build-deb-container.log)
if [[ ! -f "$DEB_FILE" ]]; then
echo "build-deb-container.sh did not name a package: '$DEB_FILE'" >&2
exit 1
fi
case "$DEB_FILE" in
*_${{ matrix.deb_arch }}.deb) ;;
*)
echo "Package $DEB_FILE is not ${{ matrix.deb_arch }}" >&2
exit 1
;;
esac
- name: Build release binaries
run: cargo build --release
# Record it relative to the checkout: upload-artifact derives the
# archive layout from the common ancestor of its paths, and an
# absolute path here would nest the package under directories the
# release job's dist/*.deb glob does not look in.
echo "deb=${DEB_FILE#"$PWD"/}" >> "$GITHUB_OUTPUT"
# The container writes its target directory to a Docker volume, so the
# runner's target/release is empty. Recover the four binaries from the
# package instead: they are the container-built ones, so the tarball ships
# what the package ships rather than a second, runner-built set that the
# floor check never saw and that no package manager would refuse.
- name: Stage container-built binaries for the tarball
shell: bash
run: |
set -euo pipefail
UNPACK=$(mktemp -d)
dpkg-deb -x "${{ steps.deb.outputs.deb }}" "$UNPACK"
mkdir -p target/release
for bin in fips fipsctl fipstop fips-gateway; do
if [[ ! -f "$UNPACK/usr/bin/$bin" ]]; then
echo "Package is missing usr/bin/$bin" >&2
exit 1
fi
install -m 0755 "$UNPACK/usr/bin/$bin" "target/release/$bin"
done
rm -rf "$UNPACK"
- name: Build systemd tarball
env:
@@ -104,11 +153,23 @@ jobs:
--arch "${{ matrix.artifact_arch }}" \
--no-build
- name: Build Debian package
# The tarball has no package manager to refuse it, so nothing at install
# time would notice a bad floor. Check the binaries out of the finished
# tarball, after the strip, rather than trusting that they are the same
# objects the package check already passed.
- name: Check the tarball against the declared glibc floor
shell: bash
run: |
packaging/debian/build-deb.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--no-build
set -euo pipefail
TARBALL="deploy/fips-${{ needs.determine-versioning.outputs.linux_package_version }}-linux-${{ matrix.artifact_arch }}.tar.gz"
UNPACK=$(mktemp -d)
tar -xzf "$TARBALL" -C "$UNPACK"
testing/check-glibc-floor.sh \
"$UNPACK"/*/fips \
"$UNPACK"/*/fipsctl \
"$UNPACK"/*/fipstop \
"$UNPACK"/*/fips-gateway
rm -rf "$UNPACK"
- name: Resolve Linux asset paths
id: linux-assets
@@ -122,14 +183,8 @@ jobs:
exit 1
fi
DEB_FILE=$(find deploy -maxdepth 1 -type f -name "fips_*_${{ matrix.deb_arch }}.deb" | sort | head -n 1)
if [[ -z "$DEB_FILE" ]]; then
echo "Missing Debian package for ${{ matrix.deb_arch }}" >&2
exit 1
fi
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "deb=$DEB_FILE" >> "$GITHUB_OUTPUT"
echo "deb=${{ steps.deb.outputs.deb }}" >> "$GITHUB_OUTPUT"
- name: SHA-256 hashes
run: |
+18
View File
@@ -40,6 +40,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`fipstop` as "Own Loopback". `req_duplicate` returns to meaning only what it
says.
#### Packaging
- The Linux `.deb` and the systemd tarball now install and run on Debian 12 and
Ubuntu 22.04. Every Linux artifact from v0.3.0 through v0.5.0 was built on the
newest available runner, whose C library made the standard library's `pidfd`
references a hard `GLIBC_2.39` version requirement instead of the weak,
runtime-checked ones it is meant to compile to. The loader refuses an image on
that entry alone, so `fips`, `fipstop` and `fips-gateway` could not start;
`fipsctl` was unaffected, which is why an install that was checked by running
it looked healthy while the daemon was dead. No source code caused this and
none was changed. The Linux artifacts are now built in a container pinned to
the oldest supported distribution, declared with the floor in
`packaging/build-floor.env`, and every producer runs
`testing/check-glibc-floor.sh` on what it made, so a package or a tarball that
would not load fails the build rather than reaching a user. The declared
dependency is derived from the binaries instead of hand-written, so it states
the floor it was built against.
## [0.5.0] - 2026-08-30
### Added
+9 -1
View File
@@ -76,7 +76,15 @@ copyright = "2026 Johnathan Corgan"
license-file = ["LICENSE", "0"]
section = "net"
priority = "optional"
depends = "libc6, systemd, libdbus-1-3"
# "$auto" runs dpkg-shlibdeps over each packaged binary and derives the real
# dependencies, including the libc6 version floor. Written by hand this field
# said only "libc6", which no glibc fails to satisfy, so a package whose
# binaries needed 2.39 installed happily on a system with 2.35 and the daemon
# then could not start. Deriving it also picks up a libdbus floor the hand
# written list lacked, and re-derives per architecture, which matters because
# arm64 links libdbus in all four binaries where amd64 links it in one.
# systemd stays by hand: nothing links it, so nothing can derive it.
depends = "$auto, systemd"
recommends = "bluez"
extended-description = """\
FIPS is a distributed, decentralized network routing protocol for mesh \
+15 -2
View File
@@ -4,7 +4,8 @@
# All outputs are placed in deploy/ at the project root.
#
# Usage:
# make deb Build a Debian/Ubuntu .deb package
# make deb Build a Debian/Ubuntu .deb package in the pinned container
# make deb-host Build a .deb with the host toolchain (see below)
# make tarball Build a systemd install tarball
# make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier)
# make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+)
@@ -19,11 +20,23 @@ SHELL := /bin/bash
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
.PHONY: all deb tarball ipk apk aur pkg freebsd zip clean
.PHONY: all deb deb-host tarball ipk apk aur pkg freebsd zip clean
all: deb tarball
# `deb` builds in the pinned container so the package carries the declared glibc
# floor and can install on every supported distribution. It also checks that
# floor before it hands the package back.
deb:
@bash $(PACKAGING_DIR)/debian/build-deb-container.sh
# `deb-host` builds with whatever toolchain and C library the host has. It is
# for iterating locally and NOT for anything anyone else installs: on a modern
# host it produces a package that installs cleanly and then cannot start on
# Debian 12 or Ubuntu 22.04, which is the defect that made the container build
# necessary. Nothing checks its floor, deliberately, so the check stays
# attached to the artifact that ships.
deb-host:
@bash $(PACKAGING_DIR)/debian/build-deb.sh
tarball:
+22 -1
View File
@@ -7,7 +7,7 @@ and `make apk` write to `dist/` instead.
## Quick Start
```sh
make deb # Debian/Ubuntu .deb
make deb # Debian/Ubuntu .deb (built in the pinned container)
make tarball # systemd install tarball
make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier)
make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+)
@@ -18,8 +18,29 @@ make zip # Windows .zip package
make all # deb + tarball (default)
```
## The two Debian build paths
`make deb` builds in a container pinned to the oldest supported
distribution, named with the glibc floor in
[build-floor.env](build-floor.env), and checks the package it produced
against that floor before handing it back. Its only host prerequisite is
docker: the toolchain and the build dependencies live in the image. This
is the path the release workflow, the integration suite and the internal
builder all take, so a package that passes locally is built the way the
shipped one is.
`make deb-host` is the old path. It builds on the host, at whatever glibc
the host has, and it is checked against nothing. Use it for local
iteration only. A package built on a current distribution records a
version dependency that the loader refuses on Debian 12 and Ubuntu 22.04,
which is what shipped in every Linux artifact from v0.3.0 through v0.5.0,
so it must not produce anything anyone else installs.
## Build Prerequisites
The prerequisites below apply to the host-build targets. `make deb` needs
docker and nothing else.
These targets build FIPS from source, so the host needs a build
environment in addition to a Rust toolchain (the version pinned in
`rust-toolchain.toml` is auto-installed by rustup).
+35
View File
@@ -0,0 +1,35 @@
# The glibc floor for the Linux release artifacts, and the image that produces it.
#
# Sourced by packaging/debian/build-deb-container.sh and by
# testing/check-glibc-floor.sh. It exists so the floor is a decision written
# down in one place rather than a side effect of whichever build host ran last.
#
# The rule it encodes: FIPS installs on every version of a supported operating
# system that its distributor still supports for free. As of 2026-09-05 that is
#
# Ubuntu 22.04 glibc 2.35 free support ends April 2027
# Debian 12 glibc 2.36 LTS ends 2028-06-30
# Ubuntu 24.04 glibc 2.39
# Debian 13 glibc 2.41 LTS ends 2030-06-30
# Ubuntu 26.04 glibc 2.43
#
# so the lowest is Ubuntu 22.04 and the floor is its 2.35. Debian 11 left the
# set on 2026-08-31 and is deliberately not counted.
#
# Deliberately NOT a GitHub runner label. Runner availability follows GitHub's
# rule of supporting the newest two images; the floor follows distributors'
# support windows. Those are different clocks, and ubuntu-26.04 being in preview
# means the ubuntu-22.04 runner will very likely retire before Canonical's date.
# A container base outlives the runner label and builds the same way on a
# developer's machine, which is what lets local and GitHub CI do identical work.
#
# Changing FIPS_GLIBC_FLOOR drops support for every distribution below it. Check
# the table above first, and expect check-glibc-floor.sh to hold you to it.
# Base image for the build. Pinned to the oldest supported distribution.
FIPS_BUILD_IMAGE="ubuntu:22.04"
# Highest glibc symbol version any shipped binary may require. Building on
# FIPS_BUILD_IMAGE currently yields 2.34, one step below this, so there is a
# little headroom: the check is an upper bound, not an equality.
FIPS_GLIBC_FLOOR="2.35"
+49
View File
@@ -0,0 +1,49 @@
# Build image for the Linux release artifacts.
#
# Pinned to the oldest distribution FIPS supports, because the glibc a binary is
# linked against decides the glibc it will run on. See packaging/build-floor.env
# for the floor and the reasoning; BASE is passed from there, not written here,
# so there is one place to change it.
#
# This image carries the toolchain and the build dependencies only. It never
# carries the source: the source is mounted at run time, so editing a file does
# not invalidate the image and a warm rebuild costs seconds rather than minutes.
ARG BASE=ubuntu:22.04
FROM ${BASE}
ENV DEBIAN_FRONTEND=noninteractive
# dpkg-dev is not in a bare ubuntu:22.04 and is what provides dpkg-shlibdeps,
# which cargo-deb's "$auto" dependency resolution shells out to. Without it the
# declared dependencies would silently lose their versions again.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libdbus-1-dev \
libclang-dev \
clang \
binutils \
dpkg-dev \
curl \
ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# The toolchain version is passed in, read from rust-toolchain.toml by the
# calling script, and the image tag carries it -- so the image cannot drift from
# the compiler the rest of CI uses, and bumping the pin rebuilds the image. The
# builder this replaces installed `stable` and never copied rust-toolchain.toml,
# so it compiled with a different compiler from the release and nothing said so.
ARG RUST_TOOLCHAIN
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN test -n "${RUST_TOOLCHAIN}" \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME"
ARG CARGO_DEB_VERSION=3.6.3
RUN cargo install cargo-deb --version "${CARGO_DEB_VERSION}" --locked \
&& chmod -R a+w "$CARGO_HOME"
WORKDIR /src
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# Build the Debian package in the pinned build container, then check its floor.
#
# This is the one place the Linux artifacts are produced. The release workflow,
# the CI integration job and a local run all call it, so all three build the
# same way and a package that passes locally is the package that ships. That was
# not true before: the test suite built its own package inside a Debian 12 image
# while the release built on the newest GitHub runner, so the suite could not
# exhibit a defect that only the release environment produced -- and for five
# releases it did not.
#
# Usage: build-deb-container.sh [--output-dir DIR] [--version V] [--features LIST]
# [--rebuild-image]
#
# Requires docker. The image is cached between runs and rebuilt only when the
# Dockerfile or the floor changes; the source is mounted rather than copied, so
# editing code does not invalidate it.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# shellcheck source=../build-floor.env
. "$REPO_ROOT/packaging/build-floor.env"
DEST_DIR="$REPO_ROOT/deploy"
VERSION=""
FEATURES=""
REBUILD_IMAGE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--output-dir) DEST_DIR="${2:?missing value for --output-dir}"; shift 2 ;;
--version) VERSION="${2:?missing value for --version}"; shift 2 ;;
--features) FEATURES="${2:?missing value for --features}"; shift 2 ;;
--rebuild-image) REBUILD_IMAGE=1; shift ;;
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
command -v docker >/dev/null 2>&1 || {
echo "build-deb-container: docker is required and was not found." >&2
exit 2
}
# Read the toolchain from the pin rather than choosing one here, and put it in
# the tag so a bump rebuilds the image instead of silently reusing a stale one.
RUST_TOOLCHAIN=$(awk -F'"' '/^channel *=/{print $2; exit}' "$REPO_ROOT/rust-toolchain.toml")
[ -n "$RUST_TOOLCHAIN" ] || {
echo "build-deb-container: could not read channel from rust-toolchain.toml" >&2
exit 2
}
IMAGE_TAG="fips-deb-builder:${FIPS_BUILD_IMAGE//[:\/]/-}-rust${RUST_TOOLCHAIN}"
if [ "$REBUILD_IMAGE" -eq 1 ] || ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
echo "=== Building $IMAGE_TAG from $FIPS_BUILD_IMAGE with Rust $RUST_TOOLCHAIN ===" >&2
docker build \
--build-arg "BASE=$FIPS_BUILD_IMAGE" \
--build-arg "RUST_TOOLCHAIN=$RUST_TOOLCHAIN" \
-t "$IMAGE_TAG" \
-f "$SCRIPT_DIR/Dockerfile.build" \
"$SCRIPT_DIR"
else
echo "=== Using cached $IMAGE_TAG ===" >&2
fi
# Derive the version and the timestamp on the host, where git works, and pass
# both in. The container then never runs git, which matters for two reasons: a
# worktree's .git is a file pointing outside the mount and would not resolve,
# and a bind-mounted repository trips git's dubious-ownership check.
if [ -z "$VERSION" ]; then
CRATE_VERSION=$(awk -F'"' '/^version = /{print $2; exit}' "$REPO_ROOT/Cargo.toml")
if [[ "$CRATE_VERSION" == *-dev ]]; then
GIT_DATE=$(git -C "$REPO_ROOT" log -1 --format=%cs | tr -d '-')
GIT_SHA=$(git -C "$REPO_ROOT" rev-parse --short HEAD)
DIRTY=""
[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ] && DIRTY=".dirty"
VERSION="${CRATE_VERSION%-dev}~dev+git${GIT_DATE}.${GIT_SHA}${DIRTY}-1"
else
VERSION="$CRATE_VERSION"
fi
fi
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$REPO_ROOT" log -1 --format=%ct)}"
mkdir -p "$DEST_DIR"
DEST_ABS="$(cd "$DEST_DIR" && pwd)"
echo "=== Building fips $VERSION in $IMAGE_TAG ===" >&2
# A feature build hands the whole job to build-deb.sh rather than pre-building:
# --features has to reach cargo, and build-deb.sh is also what marks the version
# so a feature package is distinguishable from the default build of the same
# commit. It refuses --features with --no-build for exactly that reason, so the
# two cases cannot share one command.
if [ -n "$FEATURES" ]; then
# build-deb.sh does the whole job here: --features has to reach cargo, and
# it is also what marks the version so a feature package is distinguishable
# from the default build of the same commit. It refuses --features with
# --no-build for that reason, so the two cases cannot share one command.
# The version still comes from the host, because the image has no git.
BUILD_CMD="packaging/debian/build-deb.sh --features '$FEATURES' --version '$VERSION' --output-dir /out"
else
BUILD_CMD="cargo build --release --locked
packaging/debian/build-deb.sh --no-build --version '$VERSION' --output-dir /out"
fi
# The source is mounted read-only so a build cannot leave artifacts in the tree.
# CARGO_TARGET_DIR and the registry live in named volumes, which is what makes a
# second run fast; they are per-base-image so a floor change does not reuse
# objects linked against the wrong C library.
VOL_SUFFIX="${FIPS_BUILD_IMAGE//[:\/]/-}"
docker run --rm \
-v "$REPO_ROOT":/src:ro \
-v "$DEST_ABS":/out \
-v "fips-deb-target-${VOL_SUFFIX}":/target \
-v "fips-deb-registry-${VOL_SUFFIX}":/usr/local/cargo/registry \
-e CARGO_TARGET_DIR=/target \
-e SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
-w /src \
"$IMAGE_TAG" \
bash -euo pipefail -c "$BUILD_CMD" >&2
DEB=$(find "$DEST_ABS" -maxdepth 1 -name "fips_*_*.deb" -newermt '-10 minutes' -print | sort | tail -1)
[ -n "$DEB" ] || { echo "build-deb-container: no .deb was produced." >&2; exit 1; }
# Check the artifact here rather than in one workflow, so every producer is
# gated: the release, the CI job, a local run and packaging/Makefile all reach
# the check through this script.
"$REPO_ROOT/testing/check-glibc-floor.sh" "$DEB" >&2
echo "=== Built $DEB ===" >&2
printf '%s\n' "$DEB"
+30 -8
View File
@@ -23,6 +23,9 @@ Options:
--features <list> Cargo features to build with (comma-separated). Marks the
auto-derived Version so the package is distinguishable
from a default build of the same commit.
--output-dir <dir> Where to put the finished .deb. Defaults to deploy/ under
the project root. Exists so the container build can write
to a mount and leave the source tree read-only.
-h, --help Show this help
EOF
}
@@ -31,6 +34,7 @@ TARGET_TRIPLE=""
VERSION_OVERRIDE=""
NO_BUILD=0
FEATURES=""
DEST_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -50,6 +54,10 @@ while [[ $# -gt 0 ]]; do
FEATURES="${2:?missing value for --features}"
shift 2
;;
--output-dir)
DEST_DIR="${2:?missing value for --output-dir}"
shift 2
;;
-h|--help)
usage
exit 0
@@ -124,9 +132,20 @@ if [[ -z "${VERSION_OVERRIDE}" ]]; then
echo "Auto-derived dev Version: ${VERSION_OVERRIDE}"
fi
elif [[ -n "${FEATURES}" ]]; then
echo "Warning: --version was given with --features, so the Version carries no" >&2
echo "feature marker and this package is indistinguishable from a default" >&2
echo "build of the same commit. Mark it yourself if that matters." >&2
# An explicit version needs the same marker for the same reason, and it is
# the only way a caller that cannot derive the version here can get one.
# The container build is that caller: it derives the version on the host
# because the image has no git, and the source is mounted read-only from a
# worktree whose .git is a file pointing outside the mount.
if [[ "${VERSION_OVERRIDE}" == *"+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')"* ]]; then
: # already marked by the caller
elif [[ "${VERSION_OVERRIDE}" == *-* ]]; then
# Split off the Debian revision so the marker lands on the upstream part.
VERSION_OVERRIDE="${VERSION_OVERRIDE%-*}+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')-${VERSION_OVERRIDE##*-}"
else
VERSION_OVERRIDE="${VERSION_OVERRIDE}+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')"
fi
echo "Feature-marked Version: ${VERSION_OVERRIDE}"
fi
# Build the .deb package
@@ -149,8 +168,11 @@ if [[ -n "${FEATURES}" ]]; then
fi
cargo "${cargo_args[@]}"
# Move output to deploy/
mkdir -p deploy
# Move output to the requested directory, or deploy/ by default. Note the
# distinction from OUTPUT_DIR above, which is cargo-deb's temporary staging
# directory and is removed by the EXIT trap.
: "${DEST_DIR:=deploy}"
mkdir -p "${DEST_DIR}"
DEB_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
if [ -z "${DEB_FILE}" ]; then
@@ -158,10 +180,10 @@ if [ -z "${DEB_FILE}" ]; then
exit 1
fi
cp "${DEB_FILE}" deploy/
cp "${DEB_FILE}" "${DEST_DIR}/"
BASENAME=$(basename "${DEB_FILE}")
echo "Package built: deploy/${BASENAME}"
echo "Package built: ${DEST_DIR}/${BASENAME}"
echo ""
echo "Install with: sudo dpkg -i deploy/${BASENAME}"
echo "Install with: sudo dpkg -i ${DEST_DIR}/${BASENAME}"
echo "Remove with: sudo dpkg -r fips"
echo "Purge with: sudo dpkg -P fips (removes config and identity keys)"
+3 -2
View File
@@ -125,11 +125,12 @@ machines. Not a Docker harness.
[`ci-local.sh`](ci-local.sh) runs the full local CI pipeline — build,
clippy, unit tests, and the integration suites (including the chaos
scenarios) — mirroring the GitHub `ci.yml` integration matrix. Run
scenarios) — mirroring the GitHub `ci.yml` integration matrices. Run
`./ci-local.sh --help` for the full option list and `--list` for the
available suites. Every run starts with a parity check that verifies the
local suite set covers the same work as the GitHub matrix, per scenario for
chaos and per distro for deb-install; a divergence fails the run. GitHub
chaos and per distro for deb-install, across every job that carries a
matrix; a divergence fails the run. GitHub
runs the same check as its own `ci-parity` job. `--check-parity` runs it
alone (see [check-ci-parity.sh](check-ci-parity.sh)).
+23 -8
View File
@@ -1,10 +1,10 @@
#!/bin/bash
# ── CI parity invariant guard ───────────────────────────────────────────────
# The GitHub integration matrix (.github/workflows/ci.yml) and the local
# default suite set (ci-local.sh) MUST run the same integration suites,
# EXCEPT for the deliberate local-only entries listed below. Adding a suite
# to one runner without the other means "local green" and "GitHub green" stop
# being equivalent claims.
# The GitHub integration matrices (.github/workflows/ci.yml, swept across every
# job) and the local default suite set (ci-local.sh) MUST run the same
# integration suites, EXCEPT for the deliberate local-only entries listed below.
# Adding a suite to one runner without the other means "local green" and
# "GitHub green" stop being equivalent claims.
#
# Deliberate local-only (NOT on the GitHub gate), with reason:
# tor-socks5 — requires live Tor network; opt-in via --with-tor,
@@ -19,8 +19,9 @@
# names differ cosmetically between runners and are ignored
# — `scenario:` is the identity.
# deb-install — per distro. GitHub splits into per-distro legs carrying
# `scenario:`; local runs the same distro set in one suite,
# enumerated by ALL_SCENARIOS in deb-install/test.sh.
# `scenario:`, in a job of their own; local runs the same
# distro set in one suite, enumerated by ALL_SCENARIOS in
# deb-install/test.sh.
# everything else — per suite name.
#
# dns-resolver is the one leg still compared at leg granularity rather than
@@ -134,7 +135,21 @@ for name, entries in arrays.items():
with open(ci_yml_path, encoding="utf-8") as fh:
doc = yaml.safe_load(fh)
include = doc["jobs"]["integration"]["strategy"]["matrix"]["include"]
# Sweep every job's matrix rather than one named job: the install legs live in
# a job of their own so the rest of the integration matrix does not wait on the
# package build, and a guard keyed to a job name reports them as local-only the
# moment they move. Identity comes from the leg's own fields, so where a leg
# lives does not matter; a leg deleted from every job still shows up as
# local-only, because the local side is the reference.
include = []
for job in (doc.get("jobs") or {}).values():
legs = (((job.get("strategy") or {}).get("matrix") or {}).get("include") or [])
if isinstance(legs, list):
include += [leg for leg in legs if isinstance(leg, dict)]
if not include:
print("check-ci-parity: no matrix include: found in any job of "
f"{ci_yml_path}; cannot verify CI parity", file=sys.stderr)
sys.exit(2)
github_chaos, github_deb, github = {}, set(), set()
malformed = []
for leg in include:
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Fail when a shipped binary needs a newer glibc than the declared floor.
#
# The defect this exists to catch installs cleanly and then cannot run. Rust's
# standard library references pidfd_spawnp and pidfd_getpid as weak undefined
# symbols guarded by a runtime check, so a binary is meant to fall back where
# the C library lacks them. Linking against a glibc that HAS them records a
# version dependency instead, and the loader refuses the whole image on that
# entry alone regardless of the symbols being weak. Every Linux artifact from
# v0.3.0 to v0.5.0 shipped that way and could not start on Debian 12 or Ubuntu
# 22.04, while apt reported success and fipsctl -- which never spawns a process
# and so never referenced those symbols -- ran perfectly.
#
# Usage: check-glibc-floor.sh <artifact|binary>...
# A .deb is unpacked and every executable under usr/bin is checked.
# Anything else is treated as a single ELF binary.
#
# Reads the floor from packaging/build-floor.env unless FIPS_GLIBC_FLOOR is set.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ -z "${FIPS_GLIBC_FLOOR:-}" ]; then
# shellcheck source=../packaging/build-floor.env
. "$REPO_ROOT/packaging/build-floor.env"
fi
FLOOR="${FIPS_GLIBC_FLOOR:?no floor declared}"
for tool in readelf dpkg dpkg-deb; do
command -v "$tool" >/dev/null 2>&1 || {
echo "check-glibc-floor: $tool is not installed; cannot check anything." >&2
echo " Refusing to report a pass I did not establish." >&2
exit 2
}
done
# The maximum glibc version a binary requires.
#
# Reads the `Version needs` section, which is the table the dynamic loader
# enforces and the one that carries the fatal entry. Do NOT compute this from
# `objdump -T | grep GLIBC_ | sort -V | tail -1`: that sorts whole lines, the
# version is not the leading field, and a data symbol at GLIBC_2.2.5 therefore
# sorts last. Measured against the shipped v0.5.0 fips binary, that pipeline
# reports 2.2.5 for a binary whose real floor is 2.39 -- it would have passed
# every affected release.
#
# Prints nothing and returns 1 when it finds no GLIBC requirement at all, so an
# unreadable or non-dynamic input cannot be scored as a pass.
max_glibc_need() {
local bin="$1" found
found=$(readelf -VW "$bin" 2>/dev/null \
| awk '/Version needs section/,0' \
| grep -oE 'GLIBC_[0-9.]+' \
| sed 's/GLIBC_//' \
| sort -V \
| tail -1) || true
[ -n "$found" ] || return 1
printf '%s\n' "$found"
# Explicit: the caller tests this status to tell "no requirement" from a
# value, so it must not be whatever printf happened to return.
return 0
}
FAILED=0
CHECKED=0
is_elf() { readelf -hW "$1" >/dev/null 2>&1; }
check_binary() {
local bin="$1" label="$2" need
if ! need=$(max_glibc_need "$bin"); then
# A static binary is a legitimate no-requirement case, so distinguish
# it from a file that could not be read rather than passing both.
if readelf -hW "$bin" >/dev/null 2>&1; then
echo " ok $label (no glibc version requirement)"
CHECKED=$((CHECKED + 1))
return
fi
echo " ERROR $label is not a readable ELF object" >&2
FAILED=$((FAILED + 1))
return
fi
CHECKED=$((CHECKED + 1))
if dpkg --compare-versions "$need" gt "$FLOOR"; then
echo " FAIL $label needs glibc $need, above the declared floor $FLOOR" >&2
FAILED=$((FAILED + 1))
else
echo " ok $label needs glibc $need"
fi
}
check_deb() {
local deb="$1" tmp
tmp=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" RETURN
dpkg-deb -x "$deb" "$tmp"
# A package legitimately ships executable shell scripts alongside its
# binaries -- fips-dns-setup and its teardown are two -- so filter to ELF
# objects rather than treating a script as an unreadable binary. A .deb
# with no ELF object at all is still an error: it means the glob or the
# layout moved and this check examined nothing.
local found=0 f
while IFS= read -r -d '' f; do
is_elf "$f" || continue
found=1
check_binary "$f" "$(basename "$deb"):$(basename "$f")"
done < <(find "$tmp" -type f -perm -u+x -print0)
if [ "$found" -eq 0 ]; then
echo " ERROR $(basename "$deb") contains no ELF executables" >&2
FAILED=$((FAILED + 1))
fi
}
[ $# -gt 0 ] || {
echo "usage: check-glibc-floor.sh <artifact|binary>..." >&2
exit 2
}
echo "=== glibc floor check (declared floor: $FLOOR) ==="
for arg in "$@"; do
[ -e "$arg" ] || { echo " ERROR $arg does not exist" >&2; FAILED=$((FAILED + 1)); continue; }
case "$arg" in
*.deb) check_deb "$arg" ;;
*) check_binary "$arg" "$(basename "$arg")" ;;
esac
done
# Nothing examined is a failure, not a pass. An argument list that matched no
# binary means the caller's glob went stale, and reporting that as green is how
# a guard quietly stops guarding.
if [ "$CHECKED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then
echo "check-glibc-floor: examined no binaries; refusing to report a pass." >&2
exit 2
fi
if [ "$FAILED" -ne 0 ]; then
echo "check-glibc-floor: $FAILED problem(s) across $CHECKED binaries." >&2
echo " A binary above the floor installs cleanly and then fails to start." >&2
echo " Build through packaging/debian/build-deb-container.sh, which pins" >&2
echo " the build image to the oldest supported distribution." >&2
exit 1
fi
echo "=== glibc floor check passed ($CHECKED binaries, all at or below $FLOOR) ==="
+41 -3
View File
@@ -1006,11 +1006,49 @@ run_dns_resolver() {
# that wedges -- a docker daemon that stops answering, a container that never
# boots. 40 minutes is well above the observed cold-cache cost of a full
# five-distro run and is not a performance budget.
#
# It bounds the container build and the five installs separately rather than
# both together: the budget was sized for the installs, and a cold build that
# ate into it would shrink theirs. Neither phase is left unbounded, which is
# the property this exists for.
DEB_INSTALL_TIMEOUT=${DEB_INSTALL_TIMEOUT:-2400}
run_deb_install() {
info "[deb-install] Running multi-distro test (slow — builds .deb + per-distro install)"
local rc=0
timeout "$DEB_INSTALL_TIMEOUT" bash testing/deb-install/test.sh 2>&1 || rc=$?
# Build once through the shared container script, then install that one
# artifact into every distro. The harness would build its own package if
# handed none, and that fallback goes through the same script -- but the
# GitHub job builds explicitly and passes `--deb`, so doing it explicitly
# here too makes the two systems read as the same work rather than leaving
# a reader to discover that a fallback happens to match.
info "[deb-install] Building the package in the pinned container"
local build_log deb rc=0
build_log=$(mktemp "/tmp/ci-deb-install-build.XXXXXX")
# stdout carries the package path on its last line, so it is captured;
# stderr stays on the console so build progress is still visible live.
timeout "$DEB_INSTALL_TIMEOUT" \
bash packaging/debian/build-deb-container.sh | tee "$build_log" || rc=$?
if [[ $rc -ne 0 ]]; then
if [[ $rc -eq 124 ]]; then
echo " ERROR: the container build exceeded ${DEB_INSTALL_TIMEOUT}s and was killed;" >&2
echo " no package was produced, so this is not an assertion failure." >&2
else
echo " ERROR: the container build failed (exit $rc); no package to install." >&2
fi
rm -f "$build_log"
record "deb-install" "$rc"
return
fi
deb=$(tail -n 1 "$build_log")
rm -f "$build_log"
if [[ -z "$deb" || ! -f "$deb" ]]; then
echo " ERROR: the container build reported success but its last line of stdout" >&2
echo " was not a package path: '${deb}'" >&2
record "deb-install" 1
return
fi
info "[deb-install] Running multi-distro install test against $deb"
rc=0
timeout "$DEB_INSTALL_TIMEOUT" bash testing/deb-install/test.sh --deb "$deb" 2>&1 || rc=$?
if [[ $rc -eq 124 ]]; then
# Say so explicitly. A bare red here reads as a failed assertion, and
# the difference matters: a timeout means no assertion was reached.
+65 -32
View File
@@ -51,6 +51,11 @@ PASS=0
FAIL=0
SKIP=0
# Set by --deb. DEB_PREPARED keeps the copy-into-cache to a single operation
# however many scenarios run in one process.
SUPPLIED_DEB=""
DEB_PREPARED=0
# ─────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────
@@ -169,6 +174,25 @@ container_systemd_version() {
build_deb() {
mkdir -p "$DEB_CACHE_DIR"
# A supplied package wins outright and bypasses the staleness check below.
# That check compares mtimes, so a cached package could otherwise beat the
# artifact the caller explicitly handed over, which is exactly the silent
# substitution this suite exists to stop making.
if [ -n "$SUPPLIED_DEB" ]; then
if [ "$DEB_PREPARED" -eq 1 ]; then
return 0
fi
if [ ! -f "$SUPPLIED_DEB" ]; then
echo " ERROR: --deb $SUPPLIED_DEB does not exist" >&2
return 1
fi
rm -f "$DEB_CACHE_DIR"/*.deb
cp "$SUPPLIED_DEB" "$DEB_CACHE_DIR/"
DEB_PREPARED=1
log "Installing the supplied package $(basename "$SUPPLIED_DEB")"
return 0
fi
local cached_deb
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
@@ -188,45 +212,25 @@ build_deb() {
log "No cached .deb, building"
fi
local builder_tag="fips-deb-test:builder"
log "Building Debian 12 cargo-deb builder image (slow on first run)"
docker build -t "$builder_tag" -f - "$REPO_ROOT" <<'DOCKERFILE' >/dev/null
FROM debian:12
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libdbus-1-dev curl ca-certificates \
libclang-dev clang && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install cargo-deb --version 3.6.3 --locked
WORKDIR /src
COPY Cargo.toml Cargo.lock build.rs LICENSE README.md ./
COPY src ./src
COPY packaging ./packaging
COPY docs ./docs
RUN cargo build --release && cargo deb --no-build
DOCKERFILE
if [ ! "$(docker images -q "$builder_tag" 2>/dev/null)" ]; then
echo " ERROR: builder image build failed"
# Build through the shared container script rather than a builder defined
# here. This suite used to compile its own package inside a Debian 12 image
# while the release compiled on the newest GitHub runner, so the package it
# tested had a lower glibc floor than the package users received and could
# not exhibit a defect that only the release environment produced. It stayed
# green through five releases that could not start on two of the five
# distributions in its own matrix.
log "Building the .deb in the pinned build container (slow on first run)"
if ! bash "$REPO_ROOT/packaging/debian/build-deb-container.sh" \
--output-dir "$DEB_CACHE_DIR" >&2; then
echo " ERROR: container build failed" >&2
return 1
fi
log "Extracting .deb from builder image"
rm -f "$DEB_CACHE_DIR"/*.deb
local cid
cid=$(docker create "$builder_tag")
docker cp "$cid:/src/target/debian/." "$DEB_CACHE_DIR/" >/dev/null 2>&1
docker rm "$cid" >/dev/null
# Cargo-deb leaves intermediate artifacts; keep just the .deb.
find "$DEB_CACHE_DIR" -mindepth 1 -not -name 'fips_*_amd64.deb' -delete 2>/dev/null || true
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
if [ -n "$cached_deb" ]; then
log "Cached at $cached_deb ($(stat -c %s "$cached_deb") bytes)"
else
echo " ERROR: no .deb produced by cargo-deb"
echo " ERROR: no .deb produced by the container build" >&2
return 1
fi
}
@@ -565,6 +569,35 @@ test_ubuntu26() { _run_deb_install_scenario ubuntu26 ubuntu:26.04; }
ALL_SCENARIOS="debian12 debian13 ubuntu22 ubuntu24 ubuntu26"
# `--deb PATH` installs a package the caller already built, which is how one
# build serves all five distributions and how GitHub CI and a local run come to
# do the same work: both build once through the container script and hand the
# result here. Keep ALL_SCENARIOS above on its own line at column zero;
# check-ci-parity.sh reads it to compare this matrix against the GitHub one.
_args=()
while [ $# -gt 0 ]; do
case "$1" in
--deb)
SUPPLIED_DEB="${2:?--deb requires a path}"
shift 2
;;
-h|--help)
echo "usage: test.sh [--deb PATH] [scenario ...]"
echo "scenarios: $ALL_SCENARIOS"
exit 0
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
_args+=("$1")
shift
;;
esac
done
set -- ${_args[@]+"${_args[@]}"}
if [ $# -eq 0 ]; then
scenarios="$ALL_SCENARIOS"
else