Skip to content

release(v1.178.0): BotScan read-authority — cap-scoped collector → nf… #634

release(v1.178.0): BotScan read-authority — cap-scoped collector → nf…

release(v1.178.0): BotScan read-authority — cap-scoped collector → nf… #634

# =============================================================================
# NFTBan — CI: Update Canonization Gate (G3-UPDATE, PR-16 slice)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
# Purpose: Enforce v1.99 update canonization invariants, starting with the
# PR-16 baseline sub-gates:
# G3-U1 update lifecycle entry
# G3-U2 current → target version detection
# G3-U3 --dry-run correctness (no mutation)
# G3-U4 preflight enforcement
#
# Future sub-gates (G3-U5 .. G3-U17 + P-1/P-2/P-3 + G3-U-REBUILD-PARITY)
# are added incrementally by PR-17 .. PR-21 per the gate spec.
#
# Architecture constraint enforced here (INV-U-001):
# Update is a BOUNDED TRIGGER into the rebuild/lifecycle pipeline —
# not a parallel execution path.
# =============================================================================
name: Update Canonization Gate
on:
pull_request:
branches: [main, master]
paths:
- 'cmd/nftban-installer/**'
- 'internal/installer/**'
- 'internal/lifecycle/**'
- 'cli/lib/nftban/cli/cmd_update*.sh'
- 'VERSION'
- '.github/workflows/ci-update-canonization.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-update-canonization-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
update-canonization:
name: Update Canonization (${{ matrix.label }})
strategy:
fail-fast: false
matrix:
include:
- label: ubuntu-24.04
runner: ubuntu-24.04
container: ''
- label: almalinux-9
runner: ubuntu-24.04
container: almalinux:9
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install system dependencies (DEB)
if: matrix.label == 'ubuntu-24.04'
run: |
sudo apt-get update -qq
sudo apt-get install -y nftables jq systemd util-linux coreutils strace
- name: Install system dependencies (RPM)
if: matrix.label == 'almalinux-9'
run: |
dnf -y install epel-release
# coreutils-single is preinstalled on almalinux:9 minimal — omit coreutils.
dnf -y install nftables jq systemd util-linux git tar gzip which procps-ng findutils sudo golang strace
- name: Set up Go (DEB only)
if: matrix.label == 'ubuntu-24.04'
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0
with:
# v1.157 PR-C: pin exact patch (matches go.mod `go 1.25.11`).
# GOTOOLCHAIN=local intentionally NOT set here: the RPM matrix legs
# build with dnf-installed Go (not this step), whose patch may differ.
go-version: '1.25.11'
# ------------------------------------------------------------------
# Unit tests — close G3-U2 (version detection) + G3-U4 (preflight)
# at the function level. This is what makes PR-16 merge-safe: unit
# coverage proves the contract before we touch runtime.
# ------------------------------------------------------------------
- name: G3-U2 + G3-U4 — unit tests for update.{Preflight,DetectVersions,BuildPlan}
shell: bash
run: |
set -Eeuo pipefail
go test -v ./internal/installer/update/...
# ------------------------------------------------------------------
# Build the installer binary so we can exercise the full dry-run
# control flow end-to-end.
# ------------------------------------------------------------------
- name: Build nftban-installer + nftban-validate
shell: bash
run: |
set -Eeuo pipefail
mkdir -p bin
go build -trimpath -o bin/nftban-installer ./cmd/nftban-installer/
go build -trimpath -o bin/nftban-validate ./cmd/nftban-validate/
test -x bin/nftban-installer
test -x bin/nftban-validate
# ------------------------------------------------------------------
# Stage a minimal VERSION + ip nftban table so preflight has enough
# host state to evaluate. We do NOT install nftban fully — that's
# out of scope for the update-canonization gate.
# ------------------------------------------------------------------
- name: Prepare host state for dry-run
shell: bash
run: |
set -Eeuo pipefail
sudo install -d /usr/lib/nftban/bin /var/lib/nftban/state /etc/nftban /etc/ssh
# Current version on disk.
sudo cp VERSION /usr/lib/nftban/VERSION
# Stage the validator binary at its FHS home so runUpdateApply
# can invoke it (fhs.NftbanValidateBin absolute path — NOT on
# default $PATH). This was caught by the G3-U-REBUILD-PARITY
# gate FC-1 on 2026-04-19 and fixed in the Go apply path.
# Staging here future-proofs against any non-dry-run CI step
# that exercises the real validator invocation.
sudo install -m 0755 bin/nftban-validate /usr/lib/nftban/bin/nftban-validate
# Seed a terminal state marker so the stale-in-progress warning
# does not fire in the happy path.
echo "COMMITTED" | sudo tee /var/lib/nftban/state/install_state >/dev/null
# Simulate a host where nftban is already authoritative:
# - Disable UFW (real install took authority long ago)
# - Flush iptables-nft ghost tables (filter/nat/mangle)
# - Create ip nftban so authority preflight P-1 passes
sudo systemctl disable --now ufw 2>/dev/null || true
sudo nft flush ruleset || true
sudo nft add table ip nftban || true
# CI containers do not run sshd, and the Ubuntu runner's default
# sshd_config has Port commented out, so the SSH-port detector
# falls through all 4 sources. Seed the state-file source
# directly — source 3 in the detection chain, which is exactly
# what a post-install upgrade host would have.
echo "22" | sudo tee /var/lib/nftban/state/ssh_port_active.state >/dev/null
# phaseDetect does not require nftband to be running for dry-run,
# only preflight does — and preflight reports the failure as one
# of the five checks rather than aborting phaseDetect.
# ------------------------------------------------------------------
# G3-U3 — --dry-run correctness: no mutation, plan rendered, valid
# exit. We run the command, capture its output, and assert (a) plan
# header is present, (b) no mutation occurred on the filesystem.
# ------------------------------------------------------------------
- name: G3-U3 — --dry-run renders plan, no mutation
shell: bash
run: |
set -Eeuo pipefail
# PR-22B: snapshot ALL protected paths, including the
# update-history.json truth surface. The previous version of
# this gate whitelisted /var/lib/nftban/state/update_plan.json
# and swallowed diff output behind "|| true" — exactly the
# shape the audit flagged as "falsely reassuring CI."
# Seed a known update-history.json FIRST, BEFORE taking the
# snapshot, so both before and after include the file. Without
# the seed, an empty-to-empty diff hides history pollution.
sudo mkdir -p /var/lib/nftban
echo '{"schema_version":"1.0","entries":[]}' | sudo tee /var/lib/nftban/update-history.json >/dev/null
before=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s\n' 2>/dev/null | sort)
before_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
before_state=$(sudo test -f /var/lib/nftban/state/install_state && sudo sha256sum /var/lib/nftban/state/install_state | awk '{print $1}' || echo "missing")
# PR-P2-3 (G3-KS-SNAPSHOT): capture kernel nft tables + firewall-
# adjacent service states for a hard before/after assertion
# around the update dry-run.
before_ks=$(bash scripts/ci-snapshot-kernel-service.sh)
# PR-P2-4 (G3-EXEC-TRACE): wrap the dry-run under strace so we
# can hard-assert the process did NOT spawn any forbidden
# mutator. Falsifiability at the syscall layer, not just the
# Go-function-call layer.
set +e
sudo bash scripts/ci-exec-trace-assert.sh \
./bin/nftban-installer --mode=upgrade --dry-run \
--source --source-dir="$PWD" \
--state-dir=/var/lib/nftban/state 2>&1 | tee /tmp/dryrun.out
rc=${PIPESTATUS[0]}
set -e
echo "dry-run exit code: $rc"
# Must always render the plan header (G3-U3 output contract).
grep -q "NFTBan Update — Plan" /tmp/dryrun.out
grep -q "Current version" /tmp/dryrun.out
grep -q "Target version" /tmp/dryrun.out
# INV-U-001 must be visible in the rendered plan (architectural
# constraint verified in the output, not buried in source).
grep -q "INV-U-001" /tmp/dryrun.out
# PR-22B hard assertions — no soft "|| true". Any deviation is a
# contract failure that must fail the gate. Size-only snapshot
# (no mtime) so touch-style no-op reopens don't trigger.
after=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s\n' 2>/dev/null | sort)
after_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
after_state=$(sudo test -f /var/lib/nftban/state/install_state && sudo sha256sum /var/lib/nftban/state/install_state | awk '{print $1}' || echo "missing")
if [[ "$before_hist" != "$after_hist" ]]; then
echo "::error::G3-U3 FAIL: update-history.json modified by dry-run (before=$before_hist after=$after_hist) — audit finding F regression"
exit 1
fi
if [[ "$before_state" != "$after_state" ]]; then
echo "::error::G3-U3 FAIL: install_state modified by dry-run (before=$before_state after=$after_state) — sf.DryRun must suppress Transition persistence"
exit 1
fi
if [[ "$before" != "$after" ]]; then
echo "::error::G3-U3 FAIL: filesystem under /var/lib/nftban or /etc/nftban changed during dry-run"
diff <(echo "$before") <(echo "$after") || true
exit 1
fi
# Hard assertion: no file under /etc/nftban/** was modified.
if ! diff -q <(echo "$before" | grep '^/etc/nftban/') \
<(echo "$after" | grep '^/etc/nftban/') >/dev/null; then
echo "::error::G3-U3 FAIL: /etc/nftban was modified during --dry-run"
exit 1
fi
# PR-P2-3 (G3-KS-SNAPSHOT): kernel + service state must be
# byte-identical. The pre-PR-P2-3 gate snapshot covered only
# filesystem truth; a future regression that mutates nftables
# tables or service states without touching tracked files
# would have slipped past.
after_ks=$(bash scripts/ci-snapshot-kernel-service.sh)
if [[ "$before_ks" != "$after_ks" ]]; then
echo "::error::G3-KS-SNAPSHOT FAIL: kernel or service state changed during update --dry-run"
diff <(echo "$before_ks") <(echo "$after_ks") || true
exit 1
fi
# Exit code sanity: 0 (committed) if preflight passes, 1 (degraded)
# if preflight fails. Never 2/3/4 for a well-formed run on a
# host that doesn't have a real daemon.
case "$rc" in
0|1) echo "G3-U3 PASS — exit=$rc (preflight result reflected correctly); history + state + fs all unchanged" ;;
*) echo "::error::G3-U3 FAIL: unexpected exit code $rc"; exit 1 ;;
esac
# ------------------------------------------------------------------
# G3-U1 — update lifecycle entry: the plan output must identify that
# the update trigger was observed by the lifecycle-capable installer,
# not the legacy shell path.
# ------------------------------------------------------------------
- name: G3-U1 — update enters installer binary (not shell)
shell: bash
run: |
set -Eeuo pipefail
# Confirm the installer binary itself printed "mode=upgrade" in
# its run header — that is proof the lifecycle-capable entry
# point was used for the initial control flow.
grep -q "mode=upgrade" /tmp/dryrun.out || {
echo "::error::G3-U1 FAIL: run header did not identify mode=upgrade"
exit 1
}
echo "G3-U1 PASS — update mode observed by installer binary"
# ------------------------------------------------------------------
# G3-U2 — current → target version detection: both must be visible
# and non-placeholder for source-install case (sourceDir provided).
# ------------------------------------------------------------------
- name: G3-U2 — current + target versions detected
shell: bash
run: |
set -Eeuo pipefail
CURRENT=$(grep 'Current version' /tmp/dryrun.out | sed 's/.*:\s*//' | head -1)
TARGET=$(grep 'Target version' /tmp/dryrun.out | sed 's/.*:\s*//' | head -1)
echo "Detected — current=[$CURRENT] target=[$TARGET]"
if [[ -z "$CURRENT" || "$CURRENT" == "(not detected)" ]]; then
echo "::error::G3-U2 FAIL: current version not detected"
exit 1
fi
if [[ -z "$TARGET" || "$TARGET" == "(not detected)" ]]; then
echo "::error::G3-U2 FAIL: target version not detected (source install)"
exit 1
fi
echo "G3-U2 PASS — current=$CURRENT target=$TARGET"
# ------------------------------------------------------------------
# G3-U4 — preflight enforcement: all seven checks must appear in the
# plan's Preflight block (PR-16 P-1..P-5 + PR-17 P-6..P-7). We don't
# require every check to pass — CI environments don't have nftband
# running — but every check MUST be evaluated and reported.
# ------------------------------------------------------------------
- name: G3-U4 — preflight reports all 7 checks
shell: bash
run: |
set -Eeuo pipefail
for name in \
authority_nftban \
service_nftband_active \
artifact_version_file \
dependency_nft \
state_no_stale_in_progress \
rebuild_recovery_available \
install_origin_coherent; do
grep -q "$name" /tmp/dryrun.out || {
echo "::error::G3-U4 FAIL: preflight check '$name' missing from plan"
exit 1
}
done
echo "G3-U4 PASS — all 7 preflight checks reported"
# ------------------------------------------------------------------
# G3-U4-deepen (PR-17) — recovery planning metadata must be rendered
# so apply (PR-18) has explicit input about rollback reachability.
# ------------------------------------------------------------------
- name: G3-U4-deepen — recovery plan metadata rendered
shell: bash
run: |
set -Eeuo pipefail
grep -q "Recovery plan" /tmp/dryrun.out || {
echo "::error::G3-U4-deepen FAIL: plan has no Recovery block"
exit 1
}
grep -q "Mechanism" /tmp/dryrun.out || {
echo "::error::G3-U4-deepen FAIL: Recovery block missing Mechanism"
exit 1
}
echo "G3-U4-deepen PASS — recovery planning metadata present"
# ------------------------------------------------------------------
# PR-18 — structural call-path audit. Scans the runUpdateApply source
# for forbidden patterns before any runtime test. Fails fast if
# apply ever gains a direct mutation surface.
# ------------------------------------------------------------------
- name: G3-U5..U10 — structural call-path audit of update scope
shell: bash
run: |
set -Eeuo pipefail
# PR-22B: widen audit scope from update_apply.go only to the
# full update reach set. The audit found dry-run writes that
# the narrower scope missed (update_dryrun.go, phaseDetect
# reuse in phases.go). Shared dispatchers in main.go are also
# checked for direct writes, but are allowed to call the
# history writer via the writeHistory helper (guarded by
# IsApplyTerminal).
srcs=(
cmd/nftban-installer/update_apply.go
cmd/nftban-installer/update_dryrun.go
)
echo "Auditing ${srcs[*]} for forbidden patterns..."
fail=0
for src in "${srcs[@]}"; do
for pat in \
'exec\.Run\("nft"[^)]*add' \
'exec\.Run\("nft"[^)]*flush' \
'exec\.Run\("nft"[^)]*delete' \
'exec\.Run\("nft"[^)]*create' \
'exec\.Run\("systemctl"[^)]*stop' \
'exec\.Run\("systemctl"[^)]*start' \
'exec\.Run\("systemctl"[^)]*restart' \
'exec\.Run\("systemctl"[^)]*reload' \
'exec\.Run\("systemctl"[^)]*enable' \
'exec\.Run\("systemctl"[^)]*disable' \
'exec\.Run\("systemctl"[^)]*mask' \
'exec\.Run\("systemctl"[^)]*unmask' \
'exec\.Run\("ufw"' \
'exec\.Run\("iptables"[^-]' \
'exec\.Run\("apt-get"[^)]*remove' \
'exec\.Run\("apt-get"[^)]*purge' \
'exec\.Run\("dnf"[^)]*remove' \
'exec\.Run\("dnf"[^)]*erase' \
'exec\.WriteFileAtomic\(.*"/etc/nftban/' \
'exec\.WriteFileAtomic\(.*"/usr/lib/nftban/' \
'os\.WriteFile\(' \
'os\.Create\(' \
'os\.MkdirAll\(' \
'os\.Rename\(' \
'\.conf\.local'; do
if grep -nE "$pat" "$src" 2>/dev/null; then
echo "::error::G3-U5..U10 FAIL: forbidden pattern '$pat' in $src"
fail=1
fi
done
done
if (( fail > 0 )); then
echo "::error::Structural audit failed — update scope contains forbidden call pattern"
exit 1
fi
echo "G3-U5..U10 structural audit PASS"
# ------------------------------------------------------------------
# PR-18 — unit tests enforce the contract mechanically.
# apply_contract_test.go self-tests the whitelist + audit harness.
# update_apply_test.go pipes every runUpdateApply trace through
# AuditRecordedCommands + AuditWrittenFiles (happy, rebuild-fail,
# validator-fail, no-retry, no-reinterpretation, conf.local
# byte-preservation).
# ------------------------------------------------------------------
- name: G3-U5..U10 — unit tests for runUpdateApply call-path purity
shell: bash
run: |
set -Eeuo pipefail
go test -v ./internal/installer/update/... ./cmd/nftban-installer/...
# ------------------------------------------------------------------
# PR-19 G3-U11 — state↔exit agreement. Structural check: every
# state.Transition(...) call inside runUpdateApply must be followed
# by a return of either that state's own ExitCode() OR the exit
# code variable that drove the state selection (preventing
# hard-coded contradictions like the original StateFailedAbort +
# ExitDegraded split).
# ------------------------------------------------------------------
- name: G3-U11 — state↔exit agreement structural check
shell: bash
run: |
set -Eeuo pipefail
src=cmd/nftban-installer/update_apply.go
# Forbidden pattern: any "sf.Transition(state.StateFailed..."
# paired with "return state.ExitDegraded" in the same branch.
# We detect this by looking for returns of literal exit constants
# adjacent to StateFailed transitions.
if grep -B3 -nE 'return state\.(ExitDegraded|ExitCommitted)' "$src" | \
grep -qE 'sf\.Transition\(state\.StateFailed'; then
echo "::error::G3-U11 FAIL: hard-coded exit literal returned after StateFailed transition — use st.ExitCode() or <phase>.ExitCode"
exit 1
fi
echo "G3-U11 PASS — no state↔exit contradiction detected structurally"
# ------------------------------------------------------------------
# PR-19 G3-U12 — history integrity. Unit tests above already cover
# every state → status mapping. This step is an explicit grep to
# catch any future "success" coercion — if someone later hard-codes
# a success status for a non-committed state, CI fails.
# ------------------------------------------------------------------
- name: G3-U12 — history status-from-state non-coercion
shell: bash
run: |
set -Eeuo pipefail
src=cmd/nftban-installer/main.go
# Every "return history.StatusSuccess" must be on a line where
# the previous non-empty line is "case state.StateCommitted:".
# This catches future changes that move StatusSuccess to a
# different case arm.
lines=$(grep -n 'history\.StatusSuccess' "$src" | wc -l)
if [[ "$lines" -eq 0 ]]; then
echo "::error::G3-U12 FAIL: no history.StatusSuccess reference found at all"
exit 1
fi
# For each line N containing StatusSuccess, confirm line N-1
# contains "case state.StateCommitted:" (after trimming).
fail=0
while IFS=: read -r lineno _; do
prev=$((lineno - 1))
prevline=$(sed -n "${prev}p" "$src")
if ! echo "$prevline" | grep -qE 'case\s+state\.StateCommitted'; then
echo "::error::G3-U12 FAIL: history.StatusSuccess at line $lineno not preceded by 'case state.StateCommitted:' — prev line: $prevline"
fail=1
fi
done < <(grep -n 'history\.StatusSuccess' "$src")
if (( fail > 0 )); then
exit 1
fi
echo "G3-U12 PASS — every history.StatusSuccess is gated by case state.StateCommitted"
# ------------------------------------------------------------------
# PR-19 G3-U13 — source/package coherence. Unit test
# TestHistoryInstallType_Source asserts --source → "source". This
# step adds a structural check: historyInstallType's switch MUST
# contain a cfg.source case. Catches accidental regression on the
# "source installs labeled as rpm" bug.
# ------------------------------------------------------------------
- name: G3-U13 — historyInstallType has source case
shell: bash
run: |
set -Eeuo pipefail
src=cmd/nftban-installer/main.go
if ! grep -qE 'case cfg\.source' "$src"; then
echo "::error::G3-U13 FAIL: historyInstallType missing 'case cfg.source' — source installs will be mislabeled"
exit 1
fi
echo "G3-U13 PASS — source case present in historyInstallType"
# ------------------------------------------------------------------
# G4: PKG-EFFECTIVE-PARITY L5 upgrade transition (Slot 5 / row 14)
# ------------------------------------------------------------------
# Honest test of v1.106.0 → current upgrade ownership transition.
# v1.106.0 DEB shipped /etc/nftban as root:root 0755 (D-NEW-12 origin);
# current DEB ships it as root:nftban 0750 (post-AUTH-HARDENING / PR #573).
# The upgrade path must converge to the current state regardless of
# pre-upgrade state. If the upgrade does NOT transition ownership,
# this gate reports failure honestly — masking is forbidden.
#
# Scope: ubuntu-24.04 matrix entry only (DEB; full systemd available).
# RPM upgrade transition can be added in a follow-up; v1.106.0 RPM
# already shipped /etc/nftban correctly via %attr() so it has no
# ownership-transition gap to test.
- name: G4 — v1.106.0 → current upgrade transition (DEB only)
if: matrix.label == 'ubuntu-24.04'
shell: bash
run: |
set -Eeuo pipefail
echo "=== G4: PKG-EFFECTIVE-PARITY L5 upgrade transition ==="
# Install build dependencies for inline DEB build
sudo apt-get install -y dpkg-dev fakeroot build-essential file curl
# Build current DEB inline (uses packaging/build_nftban.sh deb path
# — same as build-packages.yml). Binaries are already in bin/ from
# the earlier "Build nftban-installer + nftban-validate" step; we
# need the missing nftban-core + nftband binaries too.
go build -trimpath -o bin/nftban-core ./cmd/nftban-core/ 2>/dev/null || \
sudo install -m 0755 /bin/true bin/nftban-core
go build -trimpath -o bin/nftband ./cmd/nftband/ 2>/dev/null || \
sudo install -m 0755 /bin/true bin/nftband
# Build the current DEB
chmod +x packaging/build_nftban.sh
( cd packaging && ./build_nftban.sh deb ) || {
echo "::warning::G4: current DEB inline build failed — upgrade test cannot run"
echo "::warning::G4: this is acceptable for PR-B introduction; follow-up to extract DEB from build-packages.yml"
exit 0
}
CURRENT_DEB=$(ls /home/runner/work/*/*/build/packages/*.deb 2>/dev/null | head -1 || ls build/packages/*.deb 2>/dev/null | head -1 || true)
if [[ -z "$CURRENT_DEB" || ! -f "$CURRENT_DEB" ]]; then
echo "::warning::G4: current DEB not found post-build — skipping upgrade test"
exit 0
fi
echo "G4: current DEB built at $CURRENT_DEB"
# Download v1.106.0 release DEB (Ubuntu 24.04 variant)
V106_URL="https://github.com/itcmsgr/nftban/releases/download/v1.106.0/nftban-ubuntu24.04-amd64.deb"
mkdir -p /tmp/v106
if ! curl -fsSL -o /tmp/v106/nftban-v1.106.0.deb "$V106_URL"; then
echo "::warning::G4: v1.106.0 release DEB download failed — skipping upgrade test (network/release availability)"
exit 0
fi
# Install v1.106.0 (best-effort; may fail postinst due to missing
# kernel state but the package payload + ownership land regardless)
sudo apt-get install -y /tmp/v106/nftban-v1.106.0.deb || \
sudo dpkg -i /tmp/v106/nftban-v1.106.0.deb || \
echo "::warning::G4: v1.106.0 install reported errors (postinst); proceeding to stat snapshot"
# PRE-UPGRADE: snapshot the v1.106.0-installed state
echo "=== G4 PRE-UPGRADE STATE (v1.106.0 installed) ==="
PRE_MODE=$(stat -c '%a' /etc/nftban 2>/dev/null || echo "MISSING")
PRE_OWNER=$(stat -c '%U:%G' /etc/nftban 2>/dev/null || echo "MISSING")
echo "pre-upgrade /etc/nftban: $PRE_MODE $PRE_OWNER"
# HONEST: log the pre-upgrade state to GITHUB_STEP_SUMMARY for visibility
{
echo "## G4 v1.106.0 → current upgrade transition"
echo ""
echo "**Pre-upgrade** (v1.106.0 installed): \`/etc/nftban\` = $PRE_MODE $PRE_OWNER"
} >> "$GITHUB_STEP_SUMMARY"
# UPGRADE to current
echo "=== G4 UPGRADE to current ==="
sudo apt-get install -y "$CURRENT_DEB" || \
sudo dpkg -i "$CURRENT_DEB" || \
echo "::warning::G4: current install reported errors (postinst); proceeding to stat snapshot"
# POST-UPGRADE: snapshot transitioned state
echo "=== G4 POST-UPGRADE STATE (current installed) ==="
POST_MODE=$(stat -c '%a' /etc/nftban 2>/dev/null || echo "MISSING")
POST_OWNER=$(stat -c '%U:%G' /etc/nftban 2>/dev/null || echo "MISSING")
echo "post-upgrade /etc/nftban: $POST_MODE $POST_OWNER"
{
echo "**Post-upgrade** (current installed): \`/etc/nftban\` = $POST_MODE $POST_OWNER"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
# ASSERT: post-upgrade /etc/nftban must be root:nftban 0750
[[ ${#POST_MODE} -eq 3 ]] && POST_MODE="0$POST_MODE"
if [[ "$POST_MODE" != "0750" || "$POST_OWNER" != "root:nftban" ]]; then
echo "::error::G4 FAIL: v1.106.0 → current upgrade did NOT transition /etc/nftban ownership"
echo "::error:: expected post-upgrade: 0750 root:nftban"
echo "::error:: observed post-upgrade: $POST_MODE $POST_OWNER"
echo "::error:: pre-upgrade was: $PRE_MODE $PRE_OWNER"
echo "::error:: Operator decision needed: add postinst chown loop, OR document"
echo "::error:: v1.106.0 → vN as one-time ownership-transition gap in release notes."
{
echo ":x: **G4 FAILED**: upgrade did NOT transition ownership."
echo ""
echo "Pre-upgrade was \`$PRE_MODE $PRE_OWNER\`; post-upgrade is \`$POST_MODE $POST_OWNER\`."
echo "Expected: \`0750 root:nftban\`."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "G4 PASS: v1.106.0 → current upgrade transitions /etc/nftban → 0750 root:nftban"
{
echo ":white_check_mark: **G4 PASSED**: upgrade transitioned ownership correctly."
} >> "$GITHUB_STEP_SUMMARY"
summary:
name: Update Canonization summary
needs: update-canonization
if: always()
runs-on: ubuntu-latest
steps:
- name: Verdict
run: |
if [[ "${{ needs.update-canonization.result }}" != "success" ]]; then
echo "::error::Update Canonization Gate FAILED — see matrix job output"
exit 1
fi
echo "Update Canonization Gate PASSED on all matrix targets"