From e7f8460ff7a69322ef64694b8878040586737f18 Mon Sep 17 00:00:00 2001 From: "goodolclint-claude[bot]" <323206664+goodolclint-claude[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:21:48 -0500 Subject: [PATCH] ci: report package currency to a rolling issue and a data branch Acts on pre-push review findings from codex + correctness/security subagents. --- .github/workflows/package-currency.yml | 49 +++ .github/workflows/unit-tests.yml | 24 ++ .../scripts/report-package-currency.sh | 321 +++++++++++++++++ .../scripts/report-package-currency.test.sh | 335 ++++++++++++++++++ 4 files changed, 729 insertions(+) create mode 100755 tests/infrastructure/scripts/report-package-currency.sh create mode 100755 tests/infrastructure/scripts/report-package-currency.test.sh diff --git a/.github/workflows/package-currency.yml b/.github/workflows/package-currency.yml index af5beea..1774341 100644 --- a/.github/workflows/package-currency.yml +++ b/.github/workflows/package-currency.yml @@ -160,6 +160,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} volumes: - /opt/pve-integration:/opt/pve-integration + outputs: + suite_rc: ${{ steps.suite.outputs.suite_rc }} strategy: fail-fast: false matrix: @@ -215,6 +217,53 @@ jobs: name: currency-test-results-pve${{ matrix.pve_version }} path: TestResults/ + # ── Report: diff the package set, keep one rolling issue ───────────── + report: + needs: [provision, test] + # !cancelled() not always(): always() is true on cancellation too, and a + # run the operator aborted must not publish a baseline or edit the issue. + if: ${{ !cancelled() && needs.provision.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # push the ci/package-baseline data branch + issues: write # maintain the rolling issue + steps: + # Credentials persist deliberately: this job pushes the data branch. + # No pull-requests: write — the baseline goes to an unprotected data + # branch, not a PR, so nothing here can reach main or the review agent. + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Download recorded package set + uses: actions/download-artifact@v8 + with: + name: pve-package-set + path: packages/ + + - name: Report package currency + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SUITE_RC: ${{ needs.test.outputs.suite_rc }} + run: | + # Empty means the suite job was skipped or cancelled — that is not the + # same claim as "failed against current PVE". + # 3 is the only rc that means "tests ran and failed". Anything else + # non-zero means the suite never produced a verdict, and the report + # must not claim the module failed against current PVE. + if [ -z "${SUITE_RC:-}" ]; then + outcome=not-run + elif [ "${SUITE_RC}" = "0" ]; then + outcome=success + elif [ "${SUITE_RC}" = "3" ]; then + outcome=failure + else + outcome=inconclusive + fi + bash "${SCRIPTS_DIR}/report-package-currency.sh" packages "${outcome}" + # ── Cleanup: destroy all VMs (always runs) ────────────────────── cleanup: needs: [provision, test] diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b75a813..242f61a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -153,3 +153,27 @@ jobs: with: name: pester-results-${{ matrix.os }}-ps${{ matrix.ps_version }} path: TestResults/ + + # ── CI infrastructure shell self-checks ───────────────────────────── + # These guard the provisioning and reporting scripts, whose real exercise is + # a ~45-minute nested-PVE run. They stub ssh/gh/git and finish in seconds, so + # a regression in them is caught on the PR rather than a week later. + shell-selfchecks: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: prepare-test-environment self-check + run: bash tests/infrastructure/scripts/prepare-test-environment.test.sh + + - name: report-package-currency self-check + run: bash tests/infrastructure/scripts/report-package-currency.test.sh + + - name: Shell syntax check + run: | + for f in tests/infrastructure/scripts/*.sh; do + bash -n "$f" || exit 1 + done diff --git a/tests/infrastructure/scripts/report-package-currency.sh b/tests/infrastructure/scripts/report-package-currency.sh new file mode 100755 index 0000000..77890df --- /dev/null +++ b/tests/infrastructure/scripts/report-package-currency.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# Reports the result of a package-currency run (lane 2). +# +# Usage: report-package-currency.sh +# +# packages-dir directory holding the per-node -packages.txt files +# suite-outcome "success" | "failure" | "inconclusive" | "not-run" +# failure = the suite ran and tests failed (report-only) +# inconclusive = the suite could not run the tests at all +# +# The baseline lives on an unprotected data branch, NOT on main and NOT in a PR: +# +# - main is protected (required checks, required review, admin enforced), so a +# direct push is rejected; and a PR opened with GITHUB_TOKEN never fires the +# pull_request workflows its own required checks come from, so it would sit +# unmergeable forever. +# - The data branch holds one file and is written with git plumbing, so the +# job's checkout is never disturbed and no orphan-branch working-tree games +# are needed. `git log ci/package-baseline` is the drift history. +# +# Behaviour: +# - no baseline yet -> seed the data branch, no issue +# - baseline matches -> exit quietly, touch nothing +# - baseline differs -> upsert ONE rolling issue, update the branch +# - nodes disagree with each other -> always reported; that mismatch is the +# failure mode that left a node unclustered (see first-boot.sh), and is worth +# surfacing even when the package set is otherwise unchanged. +# +# Requires gh with issues:write, and a token with contents:write for the push. +# DRY_RUN=1 prints the mutating calls instead of performing them. + +set -euo pipefail + +PKG_DIR="${1:?Usage: report-package-currency.sh }" +SUITE_OUTCOME="${2:?missing suite outcome}" + +ISSUE_LABEL="pve-currency" +ISSUE_TITLE="PVE package currency: upstream drift detected" +BASELINE_BRANCH="${BASELINE_BRANCH:-ci/package-baseline}" +BASELINE_FILE="pve-package-baseline.txt" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +run() { + if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "DRY_RUN: $*" + else + "$@" + fi +} + +# ── Collect the per-node sets ─────────────────────────────────────── +shopt -s nullglob +node_files=("$PKG_DIR"/*-packages.txt) +shopt -u nullglob + +if [[ ${#node_files[@]} -lt 2 ]]; then + # Inter-node disagreement is the failure this lane exists to catch, so a + # comparison that silently did not happen is worse than a loud failure. + echo "ERROR: need >=2 node package sets to compare, got ${#node_files[@]} in ${PKG_DIR}" >&2 + exit 1 +fi + +# Pathname expansion already sorts by the current collation, so the glob is in +# node order (9a before 9b). The first is the reference: its set is what the +# baseline tracks; the others are only compared against it. +reference="${node_files[0]}" +reference_node="$(basename "$reference" -packages.txt)" +echo "Reference node: ${reference_node}" + +# dpkg cannot legitimately emit anything outside this grammar. These files come +# from a machine that just installed packages from an upstream repo, and their +# contents reach a GitHub issue body — so a row that is not a name/version pair +# means the node lied, which is itself worth failing on. +PKG_RE='^(# running-kernel|[a-z0-9][a-z0-9+.-]*(:[a-z0-9]+)?)'$'\t''[A-Za-z0-9.+:~-]+$' +for f in "${node_files[@]}"; do + # -s as well as -r: `grep -qv` on a zero-byte file returns 1, so an empty + # capture would "validate" and seed an empty baseline. + if [[ ! -r "$f" || ! -s "$f" ]]; then + echo "ERROR: ${f} is empty or unreadable" >&2 + exit 1 + fi + # grep returns 2 on an I/O error, which must not read as "no bad rows". + g_rc=0 + grep -qvE "$PKG_RE" "$f" || g_rc=$? + case "$g_rc" in + 1) ;; + 0) echo "ERROR: ${f} has rows that are not dpkg name/version pairs" >&2 + grep -nvE "$PKG_RE" "$f" | head -5 >&2 + exit 1 ;; + *) echo "ERROR: could not read ${f} (grep rc=${g_rc})" >&2 + exit 1 ;; + esac +done + +# ── Do the nodes agree with each other? ───────────────────────────── +node_mismatch="" +for f in "${node_files[@]:1}"; do + other_node="$(basename "$f" -packages.txt)" + node_rc=0 + diff -q "$reference" "$f" >/dev/null 2>&1 || node_rc=$? + if [[ "$node_rc" -gt 1 ]]; then + echo "ERROR: diff failed (rc=${node_rc}) comparing ${reference} and ${f}" >&2 + exit 1 + fi + if [[ "$node_rc" -eq 1 ]]; then + echo "WARNING: ${other_node} package set differs from ${reference_node}" + node_mismatch+=$'\n'"### \`${reference_node}\` vs \`${other_node}\`"$'\n\n```diff\n' + du_rc=0 + diff -u "$reference" "$f" > "$WORK/node-diff.raw" || du_rc=$? + if [[ "$du_rc" -gt 1 ]]; then + echo "ERROR: diff -u failed (rc=${du_rc}) for ${f}" >&2 + exit 1 + fi + # No pipe: `tail | head` dies on SIGPIPE under pipefail exactly like the + # `echo | head` this file already fixed once. Two file steps instead. + tail -n +3 "$WORK/node-diff.raw" > "$WORK/node-diff.body" + node_mismatch+="$(head -n 200 "$WORK/node-diff.body")" + if [[ "$(wc -l < "$WORK/node-diff.raw")" -gt 202 ]]; then + node_mismatch+=$'\n… truncated; see the run log for the full diff' + fi + node_mismatch+=$'\n```\n' + fi +done + +# ── Fetch the recorded baseline from the data branch ──────────────── +baseline="$WORK/baseline.txt" +have_baseline=0 +branch_sha="" + +# ls-remote --exit-code is deterministic: 0 = the ref exists, 2 = no such ref, +# anything else = transport or auth failure. The alternative — matching git's +# stderr text — breaks on a locale change or a reworded message, and would turn +# a legitimate first run into a hard failure. +lsr_rc=0 +git ls-remote --exit-code --heads origin "$BASELINE_BRANCH" >/dev/null 2>"$WORK/ls.err" || lsr_rc=$? +case "$lsr_rc" in + 0) : ;; + 2) echo "Data branch ${BASELINE_BRANCH} does not exist yet." ;; + *) echo "ERROR: could not reach origin for ${BASELINE_BRANCH} (rc=${lsr_rc}):" >&2 + cat "$WORK/ls.err" >&2 + exit 1 ;; +esac + +if [[ "$lsr_rc" -eq 0 ]]; then + git fetch --quiet origin "$BASELINE_BRANCH" + branch_sha="$(git rev-parse FETCH_HEAD)" + # Branch present but file absent is an inconsistent state, not a first run. + # Treating it as "seeded" would skip the drift diff and then build a + # parentless commit whose push is rejected — wedging the lane every week. + if ! git cat-file -e "${branch_sha}:${BASELINE_FILE}" 2>/dev/null; then + echo "ERROR: ${BASELINE_BRANCH} exists but has no ${BASELINE_FILE}" >&2 + exit 1 + fi + git show "${branch_sha}:${BASELINE_FILE}" > "$baseline" + have_baseline=1 +fi + +if [[ "$have_baseline" -eq 0 ]]; then + echo "No baseline on ${BASELINE_BRANCH} — seeding it from ${reference_node}." + baseline_status="seeded" + package_diff="" +else + # rc 0 = same, 1 = differs, anything else = diff itself failed. Treating 2 + # as "differs" would overwrite a good baseline from a failed compare. + diff_rc=0 + diff -q "$baseline" "$reference" >/dev/null 2>&1 || diff_rc=$? + case "$diff_rc" in + 0) baseline_status="unchanged"; package_diff="" ;; + 1) baseline_status="changed" + # Capture rc rather than `|| true`: a bare `|| true` would swallow a + # read error here and produce an empty diff plus a baseline update. + du_rc=0 + diff -u "$baseline" "$reference" > "$WORK/pkg-diff.raw" || du_rc=$? + if [[ "$du_rc" -gt 1 ]]; then + echo "ERROR: diff -u failed (rc=${du_rc}) rendering the package diff" >&2 + exit 1 + fi + package_diff="$(tail -n +3 "$WORK/pkg-diff.raw")" ;; + *) echo "ERROR: diff failed (rc=${diff_rc}) comparing the baseline and ${reference}" >&2 + exit 1 ;; + esac +fi + +echo "Baseline status: ${baseline_status}" + +if [[ "$baseline_status" == "unchanged" && -z "$node_mismatch" ]]; then + echo "No drift and no node mismatch. Nothing to report." + exit 0 +fi + +# ── Build the report body ─────────────────────────────────────────── +body="$WORK/body.md" +{ + echo "Automated report from the PVE package-currency lane (\`package-currency.yml\`)." + echo + echo "- Run: ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-}/actions/runs/${GITHUB_RUN_ID:-local}" + echo "- Integration suite against upgraded nodes: **${SUITE_OUTCOME}**" + echo "- Reference node: \`${reference_node}\`" + echo "- Baseline: \`${BASELINE_BRANCH}\` / \`${BASELINE_FILE}\`" + echo + if [[ "$SUITE_OUTCOME" == "failure" ]]; then + echo "> The suite failed against current PVE. This lane is report-only, so the" + echo "> workflow is green — the failure is here, not in the check status." + echo + elif [[ "$SUITE_OUTCOME" == "inconclusive" ]]; then + echo "> The suite did not produce a valid result — it failed before testing" + echo "> anything (an unreachable node, for example). Nothing below says" + echo "> whether the module works against current PVE." + echo + elif [[ "$SUITE_OUTCOME" == "not-run" ]]; then + echo "> The suite did not run, so nothing is known about behaviour against" + echo "> current PVE. Only the package set below is trustworthy." + echo + fi + if [[ -n "$node_mismatch" ]]; then + echo "## Nodes disagree" + echo + echo "The two nested nodes installed from the same ISO ended up with different" + echo "package sets. This is the shape of the failure that previously left a node" + echo "unclustered, and is worth investigating on its own." + echo "$node_mismatch" + fi + if [[ "$baseline_status" == "changed" ]]; then + echo "## Package drift since the last recorded baseline" + echo + echo '```diff' + # Herestring, not a pipe: `echo "$big" | head` dies on SIGPIPE under + # `set -o pipefail`, which killed the run on exactly the large drift + # this section exists to report. + head -n 300 <<< "$package_diff" + echo '```' + echo + echo "Lane 1 stays pinned to the ISO; this lane does not bump it." + echo "The recorded baseline on \`${BASELINE_BRANCH}\` has been updated to match." + elif [[ "$baseline_status" == "seeded" ]]; then + echo "## Baseline seeded" + echo + echo "No baseline existed, so this run recorded one on \`${BASELINE_BRANCH}\`." + fi +} > "$body" + +# GitHub rejects an issue body over 65536 characters with a 422, which under +# set -e would kill the run and produce no report at all — on exactly the large +# drift or full node divergence worth reporting. +# +# This is a backstop, deliberately redundant with the per-section caps above: +# with two nodes those caps already bound the body to roughly 30 KB, so neither +# guard is individually observable in a mutation test. It earns its place if the +# node count grows or a cap is raised. +if [[ "$(wc -c < "$body")" -gt 60000 ]]; then + echo "Report body over 60000 bytes; truncating." + head -c 60000 "$body" > "$body.trunc" + printf '\n\n… truncated; see the run log for the full detail.\n' >> "$body.trunc" + mv "$body.trunc" "$body" +fi + +echo "--- report body ---" +cat "$body" +echo "-------------------" + +# ── Update the data branch ────────────────────────────────────────── +if [[ "$baseline_status" != "unchanged" ]]; then + # Plumbing rather than checkout/commit: this builds a one-file tree and a commit + # on the branch's current tip without touching the job's working tree, so the + # data branch stays a single-file history and nothing here can disturb main's + # checkout. It is also idempotent — no local branch is created, so a re-run + # cannot collide with itself. + blob="$(git hash-object -w "$reference")" + tree="$(printf '100644 blob %s\t%s\n' "$blob" "$BASELINE_FILE" | git mktree)" + + parent_args=() + if [[ -n "$branch_sha" ]]; then + parent_args=(-p "$branch_sha") + fi + + commit_msg="ci: record PVE package baseline from ${reference_node} + +Run: ${GITHUB_RUN_ID:-local} +Suite against current PVE: ${SUITE_OUTCOME}" + + # github-actions[bot] is the identity the workflow token acts as, and the one + # with access to push this branch. Set explicitly rather than relying on git + # config: an agent session exports GIT_AUTHOR_*/GIT_COMMITTER_* for its own bot + # identity, and that must not leak into a CI-authored data commit. + commit="$( + GIT_AUTHOR_NAME="github-actions[bot]" \ + GIT_AUTHOR_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" \ + GIT_COMMITTER_NAME="github-actions[bot]" \ + GIT_COMMITTER_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" \ + git commit-tree "$tree" ${parent_args[@]+"${parent_args[@]}"} -m "$commit_msg" + )" + echo "Built baseline commit ${commit} for ${BASELINE_BRANCH}" + + run git push origin "${commit}:refs/heads/${BASELINE_BRANCH}" + echo "Baseline recorded on ${BASELINE_BRANCH}." +fi + +# ── Upsert the rolling issue ──────────────────────────────────────── +# One issue, edited in place. A new issue per weekly tick would be noise. +if [[ "$baseline_status" == "changed" || -n "$node_mismatch" ]]; then + # A failed query must not be read as "no issue exists" — that turns the + # upsert into an append and quietly breaks the one-issue invariant. + if ! existing="$(gh issue list --label "$ISSUE_LABEL" --state open --limit 1 --json number --jq '.[0].number // empty')"; then + echo "ERROR: could not query open ${ISSUE_LABEL} issues" >&2 + exit 1 + fi + if [[ -n "$existing" && ! "$existing" =~ ^[0-9]+$ ]]; then + echo "ERROR: unexpected issue number from gh: ${existing}" >&2 + exit 1 + fi + if [[ -n "$existing" ]]; then + echo "Updating rolling issue #${existing}" + run gh issue edit "$existing" --body-file "$body" + run gh issue comment "$existing" --body "Refreshed by run ${GITHUB_RUN_ID:-local} — suite: ${SUITE_OUTCOME}, baseline: ${baseline_status}." + else + echo "Creating rolling issue" + run gh issue create --title "$ISSUE_TITLE" --label "$ISSUE_LABEL" --body-file "$body" + fi +fi diff --git a/tests/infrastructure/scripts/report-package-currency.test.sh b/tests/infrastructure/scripts/report-package-currency.test.sh new file mode 100755 index 0000000..a6f28bc --- /dev/null +++ b/tests/infrastructure/scripts/report-package-currency.test.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# Self-check for report-package-currency.sh. +# +# Uses a REAL temp git repo with a REAL bare remote, so fetch / hash-object / +# mktree / commit-tree / push all run for real and the data-branch behaviour is +# actually exercised. Only `gh` is stubbed — it is the one thing that would +# reach the network. +# +# Run: bash tests/infrastructure/scripts/report-package-currency.test.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="$SCRIPT_DIR/report-package-currency.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" + +cat > "$TMP/bin/gh" <<'STUB' +#!/usr/bin/env bash +echo "gh $*" >> "$STUB_LOG" +if [[ "$1" == "issue" && "$2" == "list" ]]; then + # GH_LIST_FAIL=1 simulates an API/auth/rate-limit failure, which must not be + # read as "no issue exists" — that turns the upsert into an append. + [[ "${GH_LIST_FAIL:-0}" == "1" ]] && exit 1 + [[ -n "${EXISTING_ISSUE:-}" ]] && echo "${EXISTING_ISSUE}" +fi +exit 0 +STUB +chmod +x "$TMP/bin/gh" +export PATH="$TMP/bin:$PATH" +export BASELINE_BRANCH="ci/package-baseline" + +# A session exports these for its own bot identity; the script must override +# them so CI-authored data commits are github-actions[bot]. Set them here so +# the test proves the override rather than inheriting a clean environment. +export GIT_AUTHOR_NAME="someone-else" +export GIT_AUTHOR_EMAIL="someone-else@example.com" +export GIT_COMMITTER_NAME="someone-else" +export GIT_COMMITTER_EMAIL="someone-else@example.com" + +fail=0 +pass() { echo " ok: $1"; } +fatal() { echo " FAIL: $1"; fail=1; } +check() { + local desc="$1" needle="$2" want="$3" found=no + cat "$STUB_LOG" "$OUT" 2>/dev/null | grep -q -- "$needle" && found=yes + [[ "$found" == "$want" ]] && echo " ok: $desc" \ + || { echo " FAIL: $desc (expected present=$want, got present=$found)"; fail=1; } +} + +SET_A=$'libc6:amd64\t2:2.36-9+deb12u13+b1\npve-manager\t9.2.1~rc1\nproxmox-kernel-6.14\t6.14.11-1\n# running-kernel\t6.14.11-1-pve\n' +SET_B=$'libc6:amd64\t2:2.36-9+deb12u14+b1\npve-manager\t9.2.2~rc1\nproxmox-kernel-6.14\t6.14.11-2\n# running-kernel\t6.14.11-2-pve\n' + +# Fresh repo + bare remote per case, so cases cannot leak state into each other. +newrepo() { + local n="$1" + REMOTE="$TMP/remote$n.git"; REPO="$TMP/repo$n" + git init --quiet --bare "$REMOTE" + git init --quiet "$REPO" + git -C "$REPO" remote add origin "$REMOTE" + echo seed > "$REPO/README" + git -C "$REPO" add README + git -C "$REPO" -c user.name=t -c user.email=t@e commit --quiet -m seed + git -C "$REPO" push --quiet origin HEAD:refs/heads/main + PKGS="$REPO/packages"; mkdir -p "$PKGS" +} +mkpkgs() { printf '%s' "$2" > "$PKGS/$1-packages.txt"; } +# Compare via files: $(...) strips trailing newlines, which would make an +# exact-content assertion fail against a set that legitimately ends in one. +baseline_matches() { + git -C "$REMOTE" show "refs/heads/${BASELINE_BRANCH}:pve-package-baseline.txt" > "$TMP/got.txt" 2>/dev/null || return 1 + printf '%s' "$1" > "$TMP/want.txt" + diff -q "$TMP/got.txt" "$TMP/want.txt" >/dev/null 2>&1 +} +run_target() { ( cd "$REPO" && bash "$TARGET" packages "$1" ); } + +echo "case 1: no baseline yet — seed the branch, no issue" +newrepo 1; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log1"; : > "$STUB_LOG"; export OUT="$TMP/out1" +run_target success > "$OUT" 2>&1 +check "no issue for a seed" "gh issue create" no +grep -q "Baseline seeded" "$OUT" && pass "report says it seeded" || fatal "seed not described" +baseline_matches "$SET_A" && pass "baseline seeded on the data branch" || fatal "baseline not on the data branch" +author="$(git -C "$REMOTE" log -1 --format='%an <%ae>' "refs/heads/${BASELINE_BRANCH}")" +[[ "$author" == "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" ]] \ + && pass "commit authored by github-actions[bot]" || fatal "wrong author: $author" +files="$(git -C "$REMOTE" ls-tree --name-only "refs/heads/${BASELINE_BRANCH}")" +[[ "$files" == "pve-package-baseline.txt" ]] && pass "data branch holds only the baseline" || fatal "unexpected tree: $files" +# The plumbing approach exists so the checkout is never touched: no local +# branch, no branch switch, no dirty tree. A regression to checkout/commit +# would still push correctly and pass every assertion above. +git -C "$REPO" rev-parse --verify --quiet "refs/heads/${BASELINE_BRANCH}" >/dev/null \ + && fatal "a local ${BASELINE_BRANCH} branch was created" \ + || pass "no local data branch created" +[[ "$(git -C "$REPO" rev-parse --abbrev-ref HEAD)" != "$BASELINE_BRANCH" ]] \ + && pass "checkout stayed on its original branch" || fatal "checkout was switched" +# -uno: the downloaded packages/ dir is legitimately untracked here and in CI. +# What must not happen is a change to TRACKED files — a stray commit, a +# checkout switch, or an index left half-staged. +[[ -z "$(git -C "$REPO" status --porcelain -uno)" ]] \ + && pass "no tracked files touched" || fatal "tracked files were modified" + +echo "case 2: baseline matches — must stay silent and not commit" +newrepo 2; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log2"; : > "$STUB_LOG"; export OUT="$TMP/out2" +run_target success > /dev/null 2>&1 # seed +before="$(git -C "$REMOTE" rev-parse "refs/heads/${BASELINE_BRANCH}")" +: > "$STUB_LOG" +run_target success > "$OUT" 2>&1 # second run, same packages +check "no issue created" "gh issue create" no +after="$(git -C "$REMOTE" rev-parse "refs/heads/${BASELINE_BRANCH}")" +[[ "$before" == "$after" ]] && pass "no new commit when unchanged" || fatal "committed despite no drift" +grep -q "Nothing to report" "$OUT" && pass "says nothing to report" || fatal "did not report quiet exit" + +echo "case 3: drift — issue created and the branch advances with history" +newrepo 3; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log3"; : > "$STUB_LOG"; export OUT="$TMP/out3" +run_target success > /dev/null 2>&1 # seed +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +: > "$STUB_LOG" +run_target success > "$OUT" 2>&1 +check "issue created" "gh issue create" yes +check "issue labelled" "gh issue create.*--label pve-currency" yes +baseline_matches "$SET_B" && pass "baseline updated to the new set" || fatal "baseline not updated" +count="$(git -C "$REMOTE" rev-list --count "refs/heads/${BASELINE_BRANCH}")" +[[ "$count" -eq 2 ]] && pass "history kept (2 commits)" || fatal "expected 2 commits, got $count" +grep -q "9.2.2~rc1" "$OUT" && pass "diff names the new version" || fatal "diff missing the new version" +grep -q '^-pve-manager' "$OUT" && grep -q '^+pve-manager' "$OUT" \ + && pass "diff has a direction (old removed, new added)" || fatal "diff direction missing" +grep -qE '^\-pve-manager\s+9\.2\.1~rc1' "$OUT" \ + && pass "old version on the minus side" || fatal "diff direction is reversed" + +echo "case 4: drift with an issue already open — edit, never create a second" +newrepo 4; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log4"; : > "$STUB_LOG"; export OUT="$TMP/out4" +run_target success > /dev/null 2>&1 +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +: > "$STUB_LOG" +EXISTING_ISSUE=42 run_target success > "$OUT" 2>&1 +check "issue edited" "gh issue edit 42" yes +check "no second issue" "gh issue create" no + +echo "case 5: nodes disagree though the baseline matches — report, no commit" +newrepo 5; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log5"; : > "$STUB_LOG"; export OUT="$TMP/out5" +run_target success > /dev/null 2>&1 +mkpkgs 9b "$SET_B" +before="$(git -C "$REMOTE" rev-parse "refs/heads/${BASELINE_BRANCH}")" +: > "$STUB_LOG" +run_target success > "$OUT" 2>&1 +check "issue raised for the mismatch" "gh issue create" yes +after="$(git -C "$REMOTE" rev-parse "refs/heads/${BASELINE_BRANCH}")" +[[ "$before" == "$after" ]] && pass "no commit when only the nodes disagree" || fatal "committed on a mismatch-only run" +grep -q "Nodes disagree" "$OUT" && pass "report names the mismatch" || fatal "mismatch heading missing" +grep -q "9.2.2~rc1" "$OUT" && pass "mismatch report includes the diff body" || fatal "mismatch diff body missing" + +echo "case 6: very large drift — must still report (SIGPIPE regression)" +newrepo 6 +python3 -c " +import io +old=[]; new=[] +for i in range(3000): + old.append('pkg-%04d\t1.0.%d' % (i, i)) + new.append('pkg-%04d\t2.0.%d' % (i, i)) +io.open('$PKGS/9a-packages.txt','w').write('\n'.join(old)+'\n') +io.open('$PKGS/9b-packages.txt','w').write('\n'.join(old)+'\n') +" +export STUB_LOG="$TMP/log6"; : > "$STUB_LOG"; export OUT="$TMP/out6" +run_target success > /dev/null 2>&1 # seed with the old set +python3 -c " +import io +new=['pkg-%04d\t2.0.%d' % (i, i) for i in range(3000)] +io.open('$PKGS/9a-packages.txt','w').write('\n'.join(new)+'\n') +io.open('$PKGS/9b-packages.txt','w').write('\n'.join(new)+'\n') +" +: > "$STUB_LOG" +if run_target success > "$OUT" 2>&1; then pass "large drift did not abort"; else fatal "large drift aborted — SIGPIPE regression"; fi +check "large drift still raises the issue" "gh issue create" yes + +echo "case 7: suite outcomes are distinguished" +newrepo 7; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log7"; : > "$STUB_LOG"; export OUT="$TMP/out7" +run_target success > /dev/null 2>&1 +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +run_target failure > "$OUT" 2>&1 +grep -q "report-only" "$OUT" && pass "failure explains the green check" || fatal "failure not explained" +newrepo 8; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export OUT="$TMP/out8" +run_target success > /dev/null 2>&1 +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +run_target not-run > "$OUT" 2>&1 +grep -q "did not run" "$OUT" && pass "not-run is distinct from failure" || fatal "not-run not distinguished" +newrepo 15; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export OUT="$TMP/out15" +run_target success > /dev/null 2>&1 +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +run_target inconclusive > "$OUT" 2>&1 +grep -q "did not produce a valid result" "$OUT" \ + && pass "inconclusive is distinct from failure" || fatal "inconclusive not distinguished" +grep -q '\*\*inconclusive\*\*' "$OUT" \ + && pass "summary line reports the real outcome" || fatal "summary line does not name the outcome" + +echo "case 8: only one node reported — must be fatal" +newrepo 9; mkpkgs 9a "$SET_A" +export STUB_LOG="$TMP/log9"; : > "$STUB_LOG"; export OUT="$TMP/out9" +if run_target success > "$OUT" 2>&1; then fatal "exited 0 with one node — mismatch check silently skipped"; else pass "single node fails the run"; fi + +echo "case 9: no package files at all — must be fatal" +newrepo 10 +export STUB_LOG="$TMP/log10"; : > "$STUB_LOG"; export OUT="$TMP/out10" +if run_target success > "$OUT" 2>&1; then fatal "exited 0 with no package files"; else pass "missing package files fail the run"; fi + +echo "case 10: non-dpkg content is rejected" +newrepo 11; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +printf '%s' $'pve-manager\t9.2.1\n::error::injected\n' > "$PKGS/9a-packages.txt" +export STUB_LOG="$TMP/log11"; : > "$STUB_LOG"; export OUT="$TMP/out11" +if run_target success > "$OUT" 2>&1; then fatal "accepted a package file with injected content"; else pass "non-dpkg rows fail the run"; fi + +echo "case 12: unreadable input — diff rc=2 must be fatal, not 'differs'" +newrepo 13; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log13"; : > "$STUB_LOG"; export OUT="$TMP/out13" +run_target success > /dev/null 2>&1 +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +# Break the NON-reference node. Breaking the reference instead breaks every +# comparison at once and the run dies later at `hash-object`, satisfying +# "exit non-zero" without any guard being involved. +# +# What actually catches this now is the -r/-s readability check, which runs +# before any diff. That makes the four `diff` rc>1 guards unreachable from a +# black-box test — they are defence in depth against a file that becomes +# unreadable mid-run, not tested behaviour, and this case does not claim to +# cover them. +chmod 000 "$PKGS/9b-packages.txt" +: > "$STUB_LOG" +if run_target success > "$OUT" 2>&1; then + fatal "exited 0 with an unreadable package set" +else + pass "unreadable input fails the run" +fi +grep -q "9b-packages.txt is empty or unreadable" "$OUT" \ + && pass "failure names the unreadable file" || fatal "did not name the read failure" +check "no issue on a failed compare" "gh issue create" no +chmod 644 "$PKGS/9b-packages.txt" + +echo "case 13: fetch fails for a real reason — fatal, not 'first run'" +newrepo 14; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log14"; : > "$STUB_LOG"; export OUT="$TMP/out14" +run_target success > /dev/null 2>&1 # seed, so a baseline exists +git -C "$REPO" remote set-url origin "$TMP/definitely-not-a-repo.git" +: > "$STUB_LOG" +if run_target success > "$OUT" 2>&1; then + fatal "exited 0 on a broken remote — would report 'seeded' and lose the cause" +else + pass "unreachable remote fails the run" +fi +grep -q "could not reach origin" "$OUT" \ + && pass "failure names the unreachable remote" || fatal "remote failure not named" + +echo "case 14: a concurrent writer advanced the branch — must not clobber it" +newrepo 16; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log16"; : > "$STUB_LOG"; export OUT="$TMP/out16" +run_target success > /dev/null 2>&1 # seed +# Simulate another writer landing a commit after our fetch would have run. +other="$TMP/other"; git clone -q --branch "$BASELINE_BRANCH" "$REMOTE" "$other" +echo "someone else" > "$other/pve-package-baseline.txt" +git -C "$other" add pve-package-baseline.txt +git -C "$other" -c user.name=o -c user.email=o@e commit -qm "concurrent write" +git -C "$other" push -q origin "$BASELINE_BRANCH" +theirs="$(git -C "$REMOTE" rev-parse "refs/heads/${BASELINE_BRANCH}")" +# Our run fetches the new tip, so it parents correctly and succeeds — the point +# is that their commit stays in history and is never discarded. +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +: > "$STUB_LOG" +run_target success > "$OUT" 2>&1 || true +if git -C "$REMOTE" merge-base --is-ancestor "$theirs" "refs/heads/${BASELINE_BRANCH}"; then + pass "the concurrent commit is still in history" +else + fatal "the concurrent commit was discarded — force-push or wrong parent" +fi + +echo "case 15: branch exists without the baseline file — must be fatal" +newrepo 17; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log17"; : > "$STUB_LOG"; export OUT="$TMP/out17" +run_target success > /dev/null 2>&1 # seed +# Replace the branch with a commit holding a DIFFERENT filename. Treating this +# as "first run" would skip the drift diff and then build a parentless commit +# whose push is rejected — wedging the lane on every subsequent run. +blob="$(git -C "$REPO" hash-object -w "$PKGS/9a-packages.txt")" +tree="$(printf '100644 blob %s\tsomething-else.txt\n' "$blob" | git -C "$REPO" mktree)" +c="$(git -C "$REPO" -c user.name=o -c user.email=o@e commit-tree "$tree" -m "wrong file")" +git -C "$REPO" push -q -f origin "$c:refs/heads/${BASELINE_BRANCH}" +: > "$STUB_LOG" +if run_target success > "$OUT" 2>&1; then + fatal "exited 0 with a branch that has no baseline file" +else + pass "branch without the baseline file fails the run" +fi +grep -q "has no pve-package-baseline.txt" "$OUT" \ + && pass "failure names the inconsistent branch" || fatal "inconsistent branch not named" + +echo "case 16: enormous node divergence — body must be capped, not 422" +newrepo 18 +python3 -c " +import io +a=['pkg-%04d\t1.0.%d' % (i,i) for i in range(3000)] +b=['pkg-%04d\t9.9.%d' % (i,i) for i in range(3000)] +io.open('$PKGS/9a-packages.txt','w').write('\n'.join(a)+'\n') +io.open('$PKGS/9b-packages.txt','w').write('\n'.join(b)+'\n') +" +export STUB_LOG="$TMP/log18"; : > "$STUB_LOG"; export OUT="$TMP/out18" +if run_target success > "$OUT" 2>&1; then pass "enormous divergence did not abort"; else fatal "enormous divergence aborted"; fi +check "issue still raised" "gh issue create" yes +# The body handed to gh must stay under GitHub's 65536-char limit. +body_line=$(grep -n -- "--- report body ---" "$OUT" | head -1 | cut -d: -f1) +end_line=$(grep -n -- "-------------------" "$OUT" | head -1 | cut -d: -f1) +body_bytes=$(sed -n "$((body_line+1)),$((end_line-1))p" "$OUT" | wc -c) +if [[ "$body_bytes" -lt 65536 ]]; then + pass "report body stayed under the GitHub limit (${body_bytes} bytes)" +else + fatal "report body is ${body_bytes} bytes — gh would 422 and the run would report nothing" +fi +grep -q "truncated" "$OUT" && pass "truncation is marked" || fatal "truncation not marked" + +echo "case 11: issue lookup fails — fatal, not a second issue" +newrepo 12; mkpkgs 9a "$SET_A"; mkpkgs 9b "$SET_A" +export STUB_LOG="$TMP/log12"; : > "$STUB_LOG"; export OUT="$TMP/out12" +run_target success > /dev/null 2>&1 +mkpkgs 9a "$SET_B"; mkpkgs 9b "$SET_B" +: > "$STUB_LOG" +if GH_LIST_FAIL=1 run_target success > "$OUT" 2>&1; then fatal "exited 0 despite a failed issue lookup"; else pass "failed issue lookup fails the run"; fi +check "no issue created on lookup failure" "gh issue create" no + +if [[ "$fail" -eq 0 ]]; then echo "PASS"; else echo "FAILED"; exit 1; fi