#!/usr/bin/env bash
#
# check-rust-stage.sh — build the Containerfile's `rust-build` stage and
# nothing else, to prove the image still compiles.
#
# The stage that breaks silently. It clones shop at ${SHOP_REV} and builds it
# with the console, so it is the one part of the image whose inputs live in
# another repo and move without any edit here. On 2026-08-09 shop@53551eb added
# `fontconfig = "0.11"`, yeslogic-fontconfig-sys links libfontconfig through
# pkg-config rather than dlopening it, and the stage installed
# `wayland-devel libxkbcommon-devel pkgconf` and nothing else. `cargo install`
# died on both profiles. The broken commit reached astra, srht and mnw before
# anyone built it, because a SHOP_REV bump is a one-line edit that looks like
# it cannot fail.
#
# Why a build rather than a static check. The obvious cheap guard is a test
# that reads shop's dependencies and asserts every one has its -devel package
# on the dnf line. It was rejected: it needs a crate-name to package-name table
# that is not mechanical (yeslogic-fontconfig-sys -> fontconfig-devel), and it
# passes whenever shop adds a dependency the table has not learned — a guard
# whose correctness depends on the same discipline that failed in the first
# place. Building the stage proves the property directly and needs to know
# nothing about what shop links. It also catches every other way this stage can
# break, rather than the one way it broke last.
#
# Only the builder. `--target rust-build` stops before the bootc stage, so this
# skips the 7 GB runtime, the browser and the profile split. What is left is the
# toolchain layer, shop, the house faces and the console.
#
# The faces are in scope as of 2026-08-17, and this is now the cheapest check on
# them: the image stopped installing fonts and started cutting them, so
# `quasi-type` is cloned and run in this stage. A `QUASI_TYPE_REV` that does not
# exist, a base whose upstream moved under its sha256, or a glyph recipe that
# stopped drawing all of Alloy's own codepoints each fail here rather than in a
# 7 GB build.
#
# Usage:
#   build/check-rust-stage.sh              # build it; say what happened
#   build/check-rust-stage.sh --self-test  # check the classifiers; builds nothing
#
# The self-test exists because the classifiers are the part of this script that
# fails invisibly: a wrong verdict still looks like a verdict. Run it after
# touching any of the three patterns.
#
# Run it after moving SHOP_REV, before pushing. That used to be an instruction
# and nothing else, which is how alloy@0eb6465 shipped a rev that could not
# build. A clean run now records the revision in `build/rust-stage-verified`,
# and `crates/alloy/tests/shop_rev_verified.rs` fails while the Containerfile
# names a different one — so the bump is refused by `cargo test` in a second,
# rather than by an hour of podman on someone else's machine. The astra sweep
# runs this script nightly (`rust-stage` in sweep.toml), which stays the
# backstop for a rev that stops building without anyone touching the pin.
#
# Exit codes follow refresh-base-digests.sh, for the same reason:
#
#   0  the stage builds.
#   1  the stage does not build. The image is unbuildable and, since the
#      install path is "build it yourself" (docs/IMAGE.md), so is Alloy.
#   3  something about the run, not about the image: no podman, no network, a
#      registry or a git remote nobody could reach, or a pinned base digest that
#      is gone from the registry. Prints `error:`, which the sweep turns into an
#      error cell rather than a finding. The last of those is a real defect, but
#      it belongs to `base-pins`, which names it DEAD and exits nonzero; here it
#      is only a build that never started.
#
# Rootless on purpose, unlike build-image.sh, which needs rootful podman for
# the bootc conversion and escalates through build/privilege.sh to get it.
# Nothing here leaves the builder stage, so the root store is not involved and
# a check should not ask for privilege it does not need. The
# consequence worth knowing: this populates the *rootless* layer cache, so it
# does not warm a subsequent `build/build-image.sh`.

set -Eeuo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTAINERFILE="$REPO_ROOT/Containerfile"

# Replaced rather than accumulated. An untagged `--target` build leaves a
# dangling image behind every night, and a nightly cell is exactly the thing
# that fills a disk quietly.
IMAGE="localhost/alloy-rust-stage:check"

# The three patterns this script classifies on, named so `--self-test` can
# assert on them. Editing one without running the self-test is how the defect
# below came back.
#
# CONTAINERFILE_ECHO matches lines podman wrote out of the Containerfile rather
# than out of the build. Everything else is matched only against what survives
# stripping these.
CONTAINERFILE_ECHO='^(STEP [0-9]+/[0-9]+:|Error: building at STEP |\+ )'
# `manifest unknown` and its friends are here rather than in a class of their
# own, and they are the reason this line grew on 2026-08-21. Both pinned base
# digests were gone from quay that day — the DEAD state refresh-base-digests.sh
# is written to name — and podman died at `creating build container`, before a
# single instruction ran. This script called that exit 1, "the rust-build stage
# does not build at SHOP_REV af8c79c2", which is a claim about shop's revision
# on evidence that never reached shop at all. A missing base is a hole in the
# evidence in exactly the way an unreachable remote is, and it already has an
# owner: `base-pins` reports it as a finding, and the die message below sends
# you there.
NETWORK='cannot reach .* to check SHOP_REV|serves zero refs|no such host|temporary failure in name resolution|connection refused|i/o timeout|TLS handshake timeout|error pinging container registry|manifest unknown|creating build container'
MISSING_LIB='could not find .* pkg-config|pkg-config .* not found|No package .* found|PKG_CONFIG_PATH|cannot find -l[a-z]'

# Exit 3, never 1: 1 is reserved for "the image does not build", and a caller
# that cannot tell a broken build from a broken network learns the wrong thing
# from both.
die() { printf 'error: %s\n' "$*" >&2; exit 3; }

trap 'rc=$?; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR

# Fixtures are real: every line below was copied from
# ~/sweep-runs/2026-08-11T224229Z on astra, the run that misclassified.
self_test() {
  local fails=0 tmp; tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN

  # The regression. STEP 4's header carries the SHOP_REV guard's own source,
  # with `$url` unexpanded; STEP 17 is the real failure. Classifying on the raw
  # log calls this a network problem and throws the finding away.
  cat > "$tmp/misclassified" <<'FIXTURE'
STEP 4/21: RUN set -eu;     url=https://makenot.work/git/max/shop.git;     refs=$(git ls-remote "$url" 2>/dev/null)       || { echo "cannot reach $url to check SHOP_REV" >&2; exit 1; }
expected one makeover themes dir, got: /root/.cargo/registry/src/i/makeover-2.4.1/themes /root/.cargo/registry/src/i/makeover-2.5.0/themes
Error: building at STEP "RUN set -eux;     set -- /root/.cargo/registry/src/*/makeover-*/themes": while running runtime: exit status 1
FIXTURE

  # The guard actually firing. The URL is expanded, which is the whole
  # difference between the message and the code that would print it.
  cat > "$tmp/really_unreachable" <<'FIXTURE'
STEP 4/21: RUN set -eu;     url=https://makenot.work/git/max/shop.git;       || { echo "cannot reach $url to check SHOP_REV" >&2; exit 1; }
cannot reach https://makenot.work/git/max/shop.git to check SHOP_REV
FIXTURE

  # The same trap one classifier down: a dnf or ENV line naming PKG_CONFIG_PATH
  # is source, not a probe that failed.
  cat > "$tmp/env_mentions_pkgconfig" <<'FIXTURE'
STEP 3/21: RUN dnf install -y pkgconf && export PKG_CONFIG_PATH=/usr/lib64/pkgconfig
error[E0433]: failed to resolve: use of undeclared crate or module `foo`
FIXTURE

  check() { # name file pattern expect(yes|no)
    local got=no
    grep -vE "$CONTAINERFILE_ECHO" "$2" | grep -qEi "$3" && got=yes
    if [ "$got" != "$4" ]; then
      printf 'self-test FAIL: %s expected %s, got %s\n' "$1" "$4" "$got" >&2
      fails=$((fails + 1))
    fi
  }

  # The 2026-08-21 case: podman never got past creating the build container,
  # because both pinned base digests had been GC'd from quay. Copied from
  # /tmp/alloy-ruststage.log on astra.
  cat > "$tmp/dead_base_pin" <<'FIXTURE'
Trying to pull quay.io/fedora/fedora@sha256:49c6900e8b5077e16de991a1732ae8a38e18ee115fa8b21dc724f7ecf50d8fe4...
Error: creating build container: initializing source docker://quay.io/fedora/fedora@sha256:49c6900e8b5077e16de991a1732ae8a38e18ee115fa8b21dc724f7ecf50d8fe4: reading manifest sha256:49c6900e8b5077e16de991a1732ae8a38e18ee115fa8b21dc724f7ecf50d8fe4 in quay.io/fedora/fedora: manifest unknown
FIXTURE

  check "a real build failure is not a network failure" "$tmp/misclassified"          "$NETWORK"     no
  check "a dead base pin is an evidence hole"           "$tmp/dead_base_pin"          "$NETWORK"     yes
  check "a dead base pin is not a missing lib"          "$tmp/dead_base_pin"          "$MISSING_LIB" no
  check "an unreachable remote still reads as one"     "$tmp/really_unreachable"     "$NETWORK"     yes
  check "a real build failure is not a missing lib"    "$tmp/misclassified"          "$MISSING_LIB" no
  check "PKG_CONFIG_PATH in a RUN line is not a probe" "$tmp/env_mentions_pkgconfig" "$MISSING_LIB" no

  [ "$fails" -eq 0 ] || { printf '%d self-test failure(s)\n' "$fails" >&2; exit 1; }
  echo "self-test: 6 passed"
}

case "${1:-}" in
  --self-test) self_test; exit 0 ;;
  -h|--help) sed -n '2,55p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
  "") ;;
  *) die "unknown argument: $1 (see --help)" ;;
esac

command -v podman >/dev/null || die "podman not found"
[ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE"

# Reported whatever happens, so a red cell names the revision it was red about
# without anyone opening the Containerfile at that commit.
SHOP_REV="$(grep -oE '^ARG SHOP_REV=[0-9a-f]{7,40}' "$CONTAINERFILE" | head -1 | cut -d= -f2 || true)"
[ -n "$SHOP_REV" ] || die "no ARG SHOP_REV line in $CONTAINERFILE"
printf 'building the rust-build stage; SHOP_REV=%s\n' "$SHOP_REV"

log="$(mktemp -t alloy-rust-stage.XXXXXX.log)"
# What the build said, with what the Containerfile says stripped out. See
# `classify_on_output_only` below for why the two have to be separated.
out="$(mktemp -t alloy-rust-stage.XXXXXX.out)"
trap 'rm -f "$log" "$out"' EXIT

# Podman writes the Containerfile's own source into the build log alongside the
# build's output: once per `STEP n/N:` header, again inside
# `Error: building at STEP "..."` when a RUN fails, and once per traced command
# as `+ ...` under `set -x`. So the log holds both the failures and the code
# that would report them, and a grep over the whole thing matches whichever
# comes first.
#
# That is not hypothetical. On 2026-08-11 this cell hit a real build failure --
# two makeover versions in the shared registry, STEP 17 -- and reported it as a
# network failure, because STEP 4's header carries the literal
# `cannot reach $url to check SHOP_REV` from the SHOP_REV guard. The `$url` sits
# there unexpanded, which is the tell. The cell scored `error` instead of
# `fail`, and `error` means the check learned nothing about the code, so
# witchbroom excluded a genuine defect from grading entirely.
#
# Every classifier below therefore reads `$out`, never `$log`. A line podman
# echoed out of the Containerfile is not evidence about the build, and no
# wording chosen for these patterns can be safe while the source they describe
# is in the same file being searched.
classify_on_output_only() {
  grep -vE "$CONTAINERFILE_ECHO" "$log" > "$out" || true
}

status=0
podman build \
  --target rust-build \
  --pull=missing \
  -t "$IMAGE" \
  -f "$CONTAINERFILE" \
  "$REPO_ROOT" 2>&1 | tee "$log" || status="${PIPESTATUS[0]}"

if [ "$status" -eq 0 ]; then
  echo
  echo "rust-build stage builds at SHOP_REV ${SHOP_REV}"

  # Record what was proved, so a later bump cannot quietly leave it behind.
  # `crates/alloy/tests/shop_rev_verified.rs` compares the Containerfile's
  # `ARG SHOP_REV` against this file and fails when they differ, which turns
  # "run this before pushing" from an instruction into a gate. The script is
  # still the only thing that writes it: a stamp is evidence of a build that
  # happened, and hand-editing it to make a test pass is the one way to make
  # this worse than nothing.
  #
  # Written on every success rather than behind a flag, and the content is the
  # revision alone so that a run at an already-recorded rev rewrites the file
  # byte-for-byte. That is what keeps the nightly sweep on astra from leaving a
  # dirty worktree behind: the file only changes when the recorded rev is
  # genuinely stale, which is a state worth seeing.
  {
    echo "# The SHOP_REV that build/check-rust-stage.sh last built clean."
    echo "# Written by that script on success. Do not edit by hand."
    echo "$SHOP_REV"
  } > "$REPO_ROOT/build/rust-stage-verified"
  echo "recorded in build/rust-stage-verified"
  exit 0
fi

# Failed. Which kind of failed is the whole value of this script: a red cell
# saying "podman exited 1" costs a person the build they are trying to avoid.
classify_on_output_only

# The evidence-hole cases first. Each of these is something outside the image:
# the registry serving the pinned base, or the git remote serving shop. The
# ls-remote wording is the Containerfile's own guard talking (it prints
# `cannot reach ... to check SHOP_REV`), and the zero-refs case is the one that
# already happened once, recorded there against GoingsOn problem 49732815.
#
# Reading `$out` rather than `$log` is what keeps that first pattern from
# matching the guard's own source instead of the guard firing.
if grep -qEi "$NETWORK" "$out"; then
  if grep -qi 'manifest unknown' "$out"; then
    die "the pinned base image is gone from the registry, so the build never started and this says nothing about the image. Run build/refresh-base-digests.sh --check to confirm, then without --check to move the pin"
  fi
  die "the build could not reach something it needs (registry or git remote), so this says nothing about the image"
fi

echo
echo "the rust-build stage does not build at SHOP_REV ${SHOP_REV}."

# The class this script exists for, named explicitly when it recurs. Anything
# reaching a linker or a pkg-config probe has got past the network and past
# cargo's resolver, so it is a missing header on the dnf line until proven
# otherwise.
if grep -qEi "$MISSING_LIB" "$out"; then
  cat <<'EOF'

It looks like a missing system library rather than a code error: a build script
probed pkg-config, or the linker wanted a library nobody installed.

shop links its system libraries rather than dlopening them, so a SHOP_REV move
can add one. Its README's Building section is the list at any revision. Add the
Fedora -devel package to the dnf line in the rust-build stage of the
Containerfile, above the shop install.
EOF
fi

exit 1
