ci: add index-integrity probe to distribution install smoke (#3218 regression guard) #6
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Distribution install smoke (brew / scoop / apt / yum) | |
| # End-to-end guardian for the four public package-manager install vectors. | |
| # | |
| # Every Wheels release fans out to Homebrew, Scoop, apt.wheels.dev and | |
| # yum.wheels.dev through independent downstream repos + workflows. Those break | |
| # in ways the release build itself can't see — and only surface when a user | |
| # can't install: | |
| # - the Homebrew formula auto-update dies when LuCLI ships a binary-less tag | |
| # (recurring; homebrew-wheels#383/#384) | |
| # - the apt stable Packages index gets clobbered to 0 bytes by a bleeding-edge | |
| # publish (#3218, fixed by apt-wheels#5 — now guarded by the index-integrity | |
| # job below, which also covers the bidirectional case) | |
| # - a tap PR never merges / a dispatch token loses scope, leaving a channel | |
| # stuck on an old version | |
| # | |
| # This workflow has two layers: | |
| # 1. index-integrity — a fast, container-free probe of the PUBLISHED apt/yum | |
| # dist indexes (Packages / repomd primary). It asserts each channel's index | |
| # is non-empty and that stable names the current GA. This catches the #3218 | |
| # clobber deterministically: the full-install legs only sample once a day, | |
| # so a clobber that lands outside the 14:00 window (the index is populated | |
| # only briefly right after a stable publish) can slip past them — but an | |
| # empty/missing index always fails this probe with a message that names the | |
| # regression. It also asserts the bleeding-edge index stays non-empty, since | |
| # the clobber was bidirectional (a stable publish could wipe BE too). | |
| # 2. the per-channel install legs — install the CLI the exact documented way | |
| # on each channel and assert `wheels --version` reports the current GA. | |
| # | |
| # Runs daily (propagation has settled by then) and on demand. It does NOT run on | |
| # `release: published` on purpose — right after a tag the channels lag, which | |
| # would be a false red; the daily run is the signal. | |
| # | |
| # Java is NOT set up by hand anywhere: every package declares/bundles it | |
| # (brew `depends_on "openjdk@21"`, the .deb `Depends: openjdk-21-jre-headless`, | |
| # the scoop manifest inlines OpenJDK, the .rpm Requires java-21) — so a missing | |
| # Java here is itself a real packaging regression worth catching. | |
| on: | |
| schedule: | |
| # Daily 14:00 UTC. Runs on the default branch (develop). Far enough after | |
| # any release that all four channels have propagated. | |
| - cron: '0 14 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| expected_version: | |
| description: "Version every channel must serve (blank = latest GA tag)" | |
| required: false | |
| default: "" | |
| pull_request: | |
| branches: [develop] | |
| paths: | |
| # Self-test when the workflow itself changes. | |
| - '.github/workflows/distribution-install-smoke.yml' | |
| permissions: | |
| contents: read | |
| jobs: | |
| resolve: | |
| name: Resolve expected version | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.v.outputs.version }} | |
| steps: | |
| - name: Determine the GA version each channel must serve | |
| id: v | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| # Untrusted (workflow_dispatch input) — kept in env and validated to | |
| # semver below; never interpolated straight into a shell command. | |
| INPUT: ${{ github.event.inputs.expected_version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -n "${INPUT:-}" ]; then | |
| VER="${INPUT#v}" | |
| echo "Using dispatch input: $VER" | |
| else | |
| VER="$(gh release view --repo "$REPO" --json tagName -q .tagName | sed 's/^v//')" | |
| echo "Latest GA tag: $VER" | |
| fi | |
| if ! printf '%s' "$VER" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| echo "::error::could not resolve a clean semver expected version (got '$VER')" | |
| exit 1 | |
| fi | |
| echo "version=$VER" >> "$GITHUB_OUTPUT" | |
| echo "All channels must serve wheels $VER" | |
| index-integrity: | |
| needs: resolve | |
| name: "Index integrity (apt + yum dists)" | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # Direct probe of the PUBLISHED dist indexes — no install, no containers. | |
| # Catches the #3218 cross-channel clobber deterministically: a regression | |
| # that empties a Packages / repomd index always fails here with a message | |
| # that names the regression, even outside the brief post-publish window the | |
| # full-install legs happen to sample. Both directions are checked because | |
| # the clobber was bidirectional (a stable publish could wipe bleeding-edge | |
| # and vice versa) — apt-wheels#5 scopes regen per-channel to prevent both. | |
| env: | |
| # Validated semver from `resolve`; passed via env (never interpolated into | |
| # the shell). The only external input the script touches. | |
| EXPECTED: ${{ needs.resolve.outputs.version }} | |
| # Cache-bust R2's CDN so a stale-but-cached good index can't mask a live | |
| # clobber. | |
| CB: ${{ github.run_id }}-${{ github.run_attempt }} | |
| steps: | |
| - name: Probe apt + yum stable/bleeding-edge indexes | |
| run: | | |
| set -uo pipefail | |
| fail=0 | |
| note() { echo "::error::$*"; fail=1; } | |
| # Retrying fetch: a transient blip must not red a daily guardian. -f | |
| # fails on HTTP >=400 so --retry-all-errors retries 5xx too. Prints | |
| # body to stdout; non-zero exit (after retries) => caller sees empty. | |
| fetch() { curl -fsSL --retry 4 --retry-delay 3 --retry-all-errors --max-time 60 "$1"; } | |
| # All grep checks feed from a here-string (grep PAT <<<"$body"), NOT a | |
| # `printf | grep` pipe. `grep -q` exits on first match without draining | |
| # stdin, so a piped printf of a 100KB index gets SIGPIPE (exit 141) and | |
| # `set -o pipefail` then reports the whole pipeline as failed EVEN WHEN | |
| # grep matched — a false red. A here-string has no upstream process to | |
| # kill, so the exit status is grep's alone. | |
| # --- apt: stable must be non-empty AND name the current GA, per arch. | |
| # The .deb is architecture-independent (`all`) from 4.0.5 on, but apt | |
| # serves a separate per-arch index — both must carry the GA stanza. | |
| for arch in amd64 arm64; do | |
| url="https://apt.wheels.dev/dists/stable/main/binary-${arch}/Packages?cb=${CB}" | |
| body="$(fetch "$url" || true)" | |
| if [ -z "$body" ]; then | |
| note "apt stable ${arch} Packages index is EMPTY or missing (#3218 clobber). url=${url%%\?*}" | |
| elif ! grep -q "Version: ${EXPECTED}" <<<"$body"; then | |
| note "apt stable ${arch} Packages index does not list wheels ${EXPECTED} (stale or partial). url=${url%%\?*}" | |
| else | |
| echo "OK apt stable ${arch}: index non-empty and lists ${EXPECTED}" | |
| fi | |
| done | |
| # --- apt: bleeding-edge must stay non-empty (bidirectional guard). | |
| # Versions float (snapshot.N), so only assert it has at least one stanza. | |
| be_url="https://apt.wheels.dev/dists/bleeding-edge/main/binary-amd64/Packages?cb=${CB}" | |
| be_body="$(fetch "$be_url" || true)" | |
| if ! grep -q "^Package:" <<<"$be_body"; then | |
| note "apt bleeding-edge amd64 Packages index is EMPTY (a stable publish may be clobbering it). url=${be_url%%\?*}" | |
| else | |
| echo "OK apt bleeding-edge amd64: index non-empty ($(grep -c '^Package:' <<<"$be_body") entries)" | |
| fi | |
| # --- yum: resolve repomd -> primary.xml.gz, assert stable names the GA. | |
| check_yum() { | |
| local ch="$1" assert_ver="$2" | |
| local repomd loc prim | |
| repomd="$(fetch "https://yum.wheels.dev/${ch}/repodata/repomd.xml?cb=${CB}" || true)" | |
| loc="$(grep -oE 'repodata/[a-f0-9]+-primary\.xml\.gz' <<<"$repomd" | head -1 || true)" | |
| if [ -z "$loc" ]; then | |
| note "yum ${ch} repomd.xml has no primary metadata (empty/missing repodata)." | |
| return | |
| fi | |
| prim="$(fetch "https://yum.wheels.dev/${ch}/${loc}?cb=${CB}" 2>/dev/null | gunzip 2>/dev/null || true)" | |
| if ! grep -q '<package ' <<<"$prim"; then | |
| note "yum ${ch} primary metadata lists no packages." | |
| elif [ -n "$assert_ver" ] && ! grep -q "ver=\"${assert_ver}\"" <<<"$prim"; then | |
| note "yum ${ch} primary metadata does not list wheels ${assert_ver} (stale or partial)." | |
| else | |
| echo "OK yum ${ch}: primary lists packages${assert_ver:+ incl. ${assert_ver}}" | |
| fi | |
| } | |
| check_yum stable "$EXPECTED" | |
| check_yum bleeding-edge "" | |
| if [ "$fail" -ne 0 ]; then | |
| echo "::error::Index-integrity check failed — see annotations above. This is the signature of the #3218 cross-channel clobber; check the apt-wheels / yum-wheels publish runs." | |
| exit 1 | |
| fi | |
| echo "All dist indexes healthy." | |
| homebrew: | |
| needs: resolve | |
| name: "Homebrew (${{ matrix.os }})" | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # macos-latest is Apple Silicon (arm64); ubuntu-latest exercises the | |
| # Linuxbrew path (amd64). Both are documented install targets. | |
| os: [macos-latest, ubuntu-latest] | |
| env: | |
| EXPECTED: ${{ needs.resolve.outputs.version }} | |
| HOMEBREW_NO_AUTO_UPDATE: "1" | |
| HOMEBREW_NO_INSTALL_FROM_API: "1" | |
| NONINTERACTIVE: "1" | |
| steps: | |
| # GitHub's ubuntu runners ship Homebrew (Linuxbrew) but do NOT put it on | |
| # PATH; macos runners do. Add it on Linux so `brew` resolves in the next | |
| # steps (GITHUB_PATH persists across steps). | |
| - name: Ensure Homebrew is on PATH (Linuxbrew) | |
| if: runner.os == 'Linux' | |
| run: echo "/home/linuxbrew/.linuxbrew/bin" >> "$GITHUB_PATH" | |
| - name: brew tap + install (the documented path) | |
| run: | | |
| set -euo pipefail | |
| brew tap wheels-dev/wheels | |
| # Newer Homebrew refuses to load a formula from a third-party tap until | |
| # it's trusted — a hard error on Linuxbrew, a warning on macOS. (Worth | |
| # surfacing in the tap's install docs for Linuxbrew users.) | |
| brew trust wheels-dev/wheels || true | |
| brew install wheels | |
| - name: Assert wheels --version == GA | |
| env: | |
| CHANNEL: "Homebrew (${{ matrix.os }})" | |
| run: | | |
| set -uo pipefail | |
| RAW="$(wheels --version 2>&1 || true)" | |
| # Extract the first semver from the output. Channels differ in format: | |
| # the LuCLI/brew build prints "Wheels Version: 4.0.4" (+ an ASCII | |
| # banner), the .deb/.rpm print "wheels 4.0.4 (stable)" — neither a | |
| # keyword filter nor a fixed prefix is portable, so take the first | |
| # x.y.z (the version always leads; the banner carries no semver). | |
| GOT="$(printf '%s\n' "$RAW" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" | |
| echo "${CHANNEL}: wheels --version => '${GOT}' (expected '${EXPECTED}')" | |
| if [ "$GOT" != "$EXPECTED" ]; then | |
| echo "::error::${CHANNEL} serves '${GOT}', expected '${EXPECTED}'" | |
| echo "--- raw wheels --version ---"; printf '%s\n' "$RAW" | head -20 | |
| exit 1 | |
| fi | |
| scoop: | |
| needs: resolve | |
| name: "Scoop (windows)" | |
| runs-on: windows-latest | |
| timeout-minutes: 20 | |
| env: | |
| EXPECTED: ${{ needs.resolve.outputs.version }} | |
| steps: | |
| - name: scoop bucket add + install (the documented path) | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { | |
| Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression | |
| } | |
| scoop bucket add wheels https://github.com/wheels-dev/scoop-wheels | |
| scoop install wheels | |
| # scoop puts shims in ~\scoop\shims and edits the *persistent* user | |
| # PATH (registry) — which the next step's shell does not inherit. | |
| # Expose the shims dir to later steps explicitly so `wheels` resolves. | |
| "$env:USERPROFILE\scoop\shims" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 | |
| - name: Assert wheels --version == GA | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $raw = (wheels --version 2>&1 | Out-String) | |
| $got = [regex]::Match($raw, '\d+\.\d+\.\d+').Value | |
| Write-Host "Scoop: wheels --version => '$got' (expected '$env:EXPECTED')" | |
| if ($got -ne $env:EXPECTED) { | |
| Write-Host "::error::Scoop serves '$got', expected '$env:EXPECTED'" | |
| Write-Host "--- raw wheels --version ---" | |
| Write-Host $raw | |
| exit 1 | |
| } | |
| apt: | |
| needs: resolve | |
| name: "apt (${{ matrix.arch }})" | |
| runs-on: ${{ matrix.runner }} | |
| timeout-minutes: 15 | |
| # arm64 now gates (hard-fail): as of 4.0.5 the Linux packages are | |
| # architecture-independent (`all` deb) and apt-wheels generates an arm64 | |
| # index, so `apt install wheels` works on arm64 (live-verified). A regression | |
| # on either arch should block. | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: amd64 | |
| runner: ubuntu-latest | |
| - arch: arm64 | |
| runner: ubuntu-24.04-arm | |
| env: | |
| EXPECTED: ${{ needs.resolve.outputs.version }} | |
| DEBIAN_FRONTEND: noninteractive | |
| steps: | |
| - name: Add apt.wheels.dev + install | |
| run: | | |
| set -euo pipefail | |
| # The published wheels.gpg is ASCII-armored, so dearmor it into the | |
| # keyring. (The apt-wheels README still shows a bare `tee` of the | |
| # armored key — the user-facing docs should be updated to dearmor to | |
| # match this; tracked as a follow-up.) | |
| curl -fsSL https://apt.wheels.dev/wheels.gpg | sudo gpg --dearmor -o /usr/share/keyrings/wheels.gpg | |
| echo "deb [signed-by=/usr/share/keyrings/wheels.gpg] https://apt.wheels.dev stable main" \ | |
| | sudo tee /etc/apt/sources.list.d/wheels.list >/dev/null | |
| sudo apt-get update | |
| sudo apt-get install -y wheels | |
| - name: Assert wheels --version == GA | |
| env: | |
| CHANNEL: "apt (${{ matrix.arch }})" | |
| run: | | |
| set -uo pipefail | |
| RAW="$(wheels --version 2>&1 || true)" | |
| # Extract the first semver from the output. Channels differ in format: | |
| # the LuCLI/brew build prints "Wheels Version: 4.0.4" (+ an ASCII | |
| # banner), the .deb/.rpm print "wheels 4.0.4 (stable)" — neither a | |
| # keyword filter nor a fixed prefix is portable, so take the first | |
| # x.y.z (the version always leads; the banner carries no semver). | |
| GOT="$(printf '%s\n' "$RAW" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" | |
| echo "${CHANNEL}: wheels --version => '${GOT}' (expected '${EXPECTED}')" | |
| if [ "$GOT" != "$EXPECTED" ]; then | |
| echo "::error::${CHANNEL} serves '${GOT}', expected '${EXPECTED}'" | |
| echo "--- raw wheels --version ---"; printf '%s\n' "$RAW" | head -20 | |
| exit 1 | |
| fi | |
| yum: | |
| needs: resolve | |
| name: "yum (rocky 9)" | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # RHEL-family, dnf4 — matches the documented `dnf config-manager --add-repo` | |
| # path. The release ships a noarch rpm; dnf serves it on this amd64 container. | |
| # Gates (hard-fail): as of 4.0.5 the rpm's wrapper resolves the RHEL/Fedora | |
| # Java-21 layout, so `wheels --version` works on Rocky 9 (was the 4.0.4 bug). | |
| container: | |
| image: rockylinux:9 | |
| env: | |
| EXPECTED: ${{ needs.resolve.outputs.version }} | |
| steps: | |
| - name: Add yum.wheels.dev + install (the documented path) | |
| run: | | |
| set -euo pipefail | |
| dnf -y install dnf-plugins-core | |
| dnf -y config-manager --add-repo https://yum.wheels.dev/wheels.repo | |
| dnf -y install wheels | |
| - name: Assert wheels --version == GA | |
| env: | |
| CHANNEL: "yum (rocky 9)" | |
| run: | | |
| set -uo pipefail | |
| RAW="$(wheels --version 2>&1 || true)" | |
| # Extract the first semver from the output. Channels differ in format: | |
| # the LuCLI/brew build prints "Wheels Version: 4.0.4" (+ an ASCII | |
| # banner), the .deb/.rpm print "wheels 4.0.4 (stable)" — neither a | |
| # keyword filter nor a fixed prefix is portable, so take the first | |
| # x.y.z (the version always leads; the banner carries no semver). | |
| GOT="$(printf '%s\n' "$RAW" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" | |
| echo "${CHANNEL}: wheels --version => '${GOT}' (expected '${EXPECTED}')" | |
| if [ "$GOT" != "$EXPECTED" ]; then | |
| echo "::error::${CHANNEL} serves '${GOT}', expected '${EXPECTED}'" | |
| echo "--- raw wheels --version ---"; printf '%s\n' "$RAW" | head -20 | |
| exit 1 | |
| fi |