mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-03 18:55:33 +00:00
ci: report package currency to a rolling issue and a data branch
Acts on pre-push review findings from codex + correctness/security subagents.
This commit is contained in:
+321
@@ -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> <suite-outcome>
|
||||
#
|
||||
# packages-dir directory holding the per-node <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 <packages-dir> <suite-outcome>}"
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user