From e87934745f233be62a4728c5d48fdeed516e1d80 Mon Sep 17 00:00:00 2001 From: "guys-inc-ops[bot]" <321481384+guys-inc-ops[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:52:13 +0000 Subject: [PATCH] Rebuild reprepro's database from the pool before publishing Excluding db/ from the bucket was right - earlier runs served reprepro's working state publicly. But `export` writes dists/ from the database, not from the pool, and the database is the one thing the state pull cannot bring back. Every run therefore starts empty and regenerates the indexes from only the packages it included itself. Today that is invisible, which is why it survived review: all three architectures ship in one run, and reprepro keeps a single version per package anyway, so the output is correct by coincidence rather than by construction. It stops being correct the moment a run publishes a subset - one architecture failing to build, a re-publish of a single asset, or a second package in the repository. Measured in a bookworm container, publishing both arches at 3.4.9 and then amd64 alone at 3.4.10: db lost (today): amd64=3.4.10 arm64= db kept: amd64=3.4.10 arm64=3.4.9 arm64 users lose the package, and the run stays green. That is the same failure the state-pull guard exists to prevent, arriving by another route. So reconstruct the database from what the bucket already holds before adding anything new. reprepro exits 0 and skips a package it already has, so a re-publish of the same tag is still idempotent, and it does not delete the pool file it is reading from - both verified rather than assumed. It also fixes a smaller live problem: superseded pool objects are currently never removed, because nothing knows they exist. The second step is the one that would have caught this. An empty index is a valid, correctly signed index, indistinguishable downstream from a repository that genuinely has no packages, so the check has to happen here, while the pool is still around to compare against. --- .github/workflows/publish-apt.yml | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.github/workflows/publish-apt.yml b/.github/workflows/publish-apt.yml index 445bf1460e..76ac8fa02e 100644 --- a/.github/workflows/publish-apt.yml +++ b/.github/workflows/publish-apt.yml @@ -134,6 +134,38 @@ jobs: mkdir -p repo/conf cp apt/conf/distributions repo/conf/distributions + - name: Rebuild the package database from the pulled pool + run: | + set -euo pipefail + # reprepro's db/ is its own working state and is deliberately not + # published - earlier runs served it publicly, and the sync now + # excludes it. But `export` writes dists/ from the *database*, not + # from the pool, and the database is the one thing the pull cannot + # bring back. Every run therefore starts with an empty db and + # regenerates the indexes from only what it included itself. + # + # Today that is invisible: all three architectures ship in one run, + # and reprepro keeps a single version per package anyway. It stops + # being invisible the moment a run publishes a subset - one + # architecture failing to build, a re-publish of a single asset, or a + # second package in the repository. Measured: publish both arches at + # 3.4.9, then amd64 only at 3.4.10, and arm64's index comes back + # *empty* while the run stays green. That is the same failure the + # state-pull guard above exists to prevent, arriving by another route. + # + # So reconstruct the database from what the bucket already holds, + # before adding anything new. reprepro exits 0 and skips a package it + # already has, so this is safe to repeat, and it does not remove the + # pool file it is reading from. + restored=0 + if [ -d repo/pool ]; then + while IFS= read -r -d '' deb; do + reprepro -b repo includedeb stable "$deb" + restored=$((restored + 1)) + done < <(find repo/pool -name '*.deb' -type f -print0 | sort -z) + fi + echo "restored $restored package(s) from the pool" + - name: Include packages (signs Release / InRelease) run: | shopt -s nullglob @@ -142,6 +174,35 @@ jobs: reprepro -b repo includedeb stable "$deb" done reprepro -b repo export + + - name: Check every architecture is still listed + run: | + set -euo pipefail + # The failure this guards against is silent by construction: an empty + # index is a valid, correctly signed index. Nothing downstream can + # tell it from a repository that genuinely has no packages, so the + # check belongs here, while the pool is still around to compare with. + architectures=$(awk -F': *' '/^Architectures:/ {print $2}' repo/conf/distributions) + suite=$(awk -F': *' '/^Codename:/ {print $2}' repo/conf/distributions) + failed=0 + for arch in $architectures; do + index="repo/dists/${suite}/main/binary-${arch}/Packages" + # grep -c prints 0 *and* exits 1 when a file has no matches, so a + # `|| echo 0` fallback appends a second count and the comparison + # below silently stops working. Ask about the file first. + listed=0 + if [ -f "$index" ]; then + listed=$(grep -c '^Package:' "$index" || true) + fi + pooled=$(find repo/pool \( -name "*_${arch}.deb" -o -name '*_all.deb' \) -type f | wc -l) + echo "${arch}: ${listed} listed, ${pooled} in pool" + if [ "$pooled" -gt 0 ] && [ "$listed" -eq 0 ]; then + echo "::error::${arch} has ${pooled} package(s) in the pool but an empty index." + echo "::error::Publishing this would unlist every ${arch} package." + failed=1 + fi + done + [ "$failed" -eq 0 ] echo "--- repo contents ---" reprepro -b repo list stable || true