Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions .github/scripts/clean-container-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# Run the module suite as a HOST-STATE gate — the engine of test.yml's
# clean-container-test job.
#
# Usage: clean-container-gate.sh (from anywhere; resolves the repo root)
#
# The job's body runs in a pristine golang:<manifest pin>-bookworm container,
# so a test that is green on the ubuntu-latest runner image and red here is
# coupled to host state (the /etc/tollgate class, $HOME leftovers, runner
# packages on PATH, warm caches) — it passes because of where it runs, not
# because of what it tests.
#
# This gate fails closed:
# * any failing test NOT listed in
# tests/contract/clean-container-known-issues.txt is fatal;
# * a listed failure is printed as TOLERATED with its issue, and the list
# is the complete, meant-to-shrink tolerance (same contract as the
# happy-path gate): when the issue closes, the line is deleted and the
# failure is fatal again by itself;
# * a module that fails to build (a FAIL with no failed test name) is
# always fatal — a build cannot be a known issue;
# * a non-zero go test exit that reports no failed test is fatal too
# (a harness failure is never tolerated).
#
# The module list mirrors the go-test matrix in test.yml (same nine nested
# modules) plus the main package's testenv run. Intentionally omitted, same
# as the matrix: src/cli, src/upstream_detector and src/upstream_session_manager
# — their go.mods transitively pull the lnd -> ltcsuite/ltcd chain, which only
# builds standalone after an invasive rewrite (the NOTE in test.yml's go-test
# job); they still build under make go-battery's root-module build. Adding
# them here is tracked where the matrix tracks it.
set -uo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
KNOWN="$ROOT/tests/contract/clean-container-known-issues.txt"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

MODULES=(
src/config_manager
src/utils
src/cmd/tollgate-cli
src/lightning
src/merchant
src/tollgate_protocol
src/tollwallet
src/valve
src/wireless_gateway_manager
)

fatal=0
tolerated=0
# Every "<test> <module>" that failed, one per line.
: >"$WORK/failures.txt"

run_lane() { # run_lane <label> <go test args...>
local label="$1"
shift
echo ""
echo "== $label"
if ! go test "$@" >"$WORK/lane.log" 2>&1; then
local names
names="$(grep -E '^--- FAIL: ' "$WORK/lane.log" | sed -E 's/^--- FAIL: ([^ ]+).*/\1/' | sort -u || true)"
if [ -z "$names" ] && [ -s "$WORK/lane.log" ]; then
sed 's/^/ /' "$WORK/lane.log" | tail -20 >&2
fi
if [ -z "$names" ]; then
echo "🔴 $label: go test exited non-zero with no failed test name (build or harness failure) — always fatal"
fatal=$((fatal + 1))
return
fi
while IFS= read -r name; do
# Subtest failures key on their top-level test for tolerance.
local top="${name%%/*}"
printf '%s\t%s\n' "$top" "$label" >>"$WORK/failures.txt"
done <<<"$names"
else
echo "✅ $label: green"
fi
}

echo "clean-container gate: root $ROOT"
echo "toolchain: $(go version)"

for module in "${MODULES[@]}"; do
run_lane "$module" -C "$ROOT/$module" -count=1 -race ./...
done
run_lane "src (main package, testenv)" -C "$ROOT/src" -count=1 -race -tags testenv .

# --- reconcile against the known-issues list ---------------------------------

# Known-issue entries promise an OPEN issue; enforce it wherever a token
# exists (the GitHub lane). A closed issue whose test still fails keeps
# tolerating silently otherwise — the exact gap the #775 review flagged.
if [ -n "${GH_TOKEN:-}${GITHUB_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
entry_issue="$(printf '%s' "$line" | grep -oE '#[0-9]+' | head -1 | tr -d '#')"
[ -n "$entry_issue" ] || { echo "🔴 FATAL: known-issue line lacks an issue reference: $line"; fatal=$((fatal + 1)); continue; }
state="$(gh issue view "$entry_issue" --repo OpenTollGate/tollgate-module-basic-go --json state --jq .state 2>/dev/null || true)"
if [ "$state" = "CLOSED" ]; then
echo "🔴 FATAL: #$entry_issue is CLOSED but its test is still tolerated — delete the entry so the failure is fatal again"
fatal=$((fatal + 1))
fi
done <"$KNOWN"
fi

echo ""
echo "== reconciliation"
while IFS=$'\t' read -r test module; do
[ -n "$test" ] || continue
if grep -Eq "^${test}\b" "$KNOWN"; then
issue="$(grep -E "^${test}\b" "$KNOWN" | head -1 | sed -E 's/^[^\t]*\t[^\t]*\t[[:space:]]*//')"
echo "🟡 TOLERATED (known issue): $test in $module — $issue"
tolerated=$((tolerated + 1))
else
echo "🔴 FATAL: $test failed in $module and is not a known issue — host-state coupling or a regression; file the issue, do not widen the gate"
fatal=$((fatal + 1))
fi
done <"$WORK/failures.txt"

# A tolerance entry whose test did NOT fail is stale — the list is meant to
# shrink, so say so loudly (a warning: flaky tests may pass sometimes).
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
entry_test="$(printf '%s' "$line" | cut -f1)"
if ! grep -qF "$entry_test" "$WORK/failures.txt"; then
echo "⚠️ stale tolerance: $entry_test did not fail — delete its line so the failure becomes fatal again"
fi
done <"$KNOWN"

summary="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
{
echo "### Clean-container gate"
echo ""
echo "| Verdict | Count |"
echo "|---------|------:|"
echo "| Fatal failures | $fatal |"
echo "| Tolerated (known issues) | $tolerated |"
} >>"$summary"

echo ""
if [ "$fatal" -ne 0 ]; then
echo "❌ clean-container gate: $fatal unlisted failure(s), $tolerated tolerated."
exit 1
fi
echo "✅ clean-container gate: no unlisted failures ($tolerated tolerated known issue(s))."
exit 0
69 changes: 57 additions & 12 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ jobs:
python3 scripts/changelog-assemble.py check
bash tests/contract/changelog-fragments_test.sh

- name: Changelog fragment identity (numbers are real, no placeholders)
env:
GH_TOKEN: ${{ github.token }}
run: |
# Offline verdicts first: every refusal case the fence names must
# actually refuse (a fence that cannot fail is decoration).
bash tests/contract/changelog-fragment-numbers_test.sh
# Then the tree, offline and online: every changelog.d/*.md must
# reference a REAL PR/issue number of its own — the numberless
# vm-campaign fragment (merged as #704) and the foreign-number
# paste (#631/#743 class) are the two shapes this stops. Online uses
# the job token via gh; unauthenticated urllib is the fallback.
python3 tests/contract/check-changelog-fragments.py
python3 tests/contract/check-changelog-fragments.py --online

- name: Schema lint
run: node tests/contract/js-schema-lint.mjs

Expand Down Expand Up @@ -118,18 +133,6 @@ jobs:
# was never wired into CI, so nothing ran it automatically.
bash tests/packaging/package-nodogsplash-dependency_test.sh

- name: GPL license ships in both release lanes
run: |
# Offline (no SDK, no router, no network): issue #751 — the SDK
# .apk lane was metadata-only while the .ipk lanes installed the
# GPL-3.0 text, so the same version shipped different contents per
# format. Every staging surface (packaging/Makefile, local-build-ipk,
# the CI ipk lane) now ships the text at the same payload path, the
# SDK recipe REQUIRES the file (a GPL build without its license
# fails, not silently ships), and the built-artifact harness
# hard-fails any package missing it.
bash tests/packaging/license-both-lanes_test.sh

- name: Setup-marker ordering (roll-back safety)
run: |
# Offline (no router, no SDK, no network): the setup script's version
Expand Down Expand Up @@ -356,6 +359,48 @@ jobs:
- name: Changelog duplicates check verdicts
run: bash tests/contract/changelog-duplicates_test.sh

# The same module suite the go-test matrix runs, but in a PRISTINE
# golang:1.26.8-bookworm container instead of the GitHub runner image.
# The image tag is a toolchain pin like any go-version: line —
# tests/contract/check-toolchain-parity.py refuses it drifting from the
# manifest.
#
# WHAT A FAILURE HERE MEANS. The runner image is anything but clean: it
# carries preinstalled toolchains and packages, a populated $HOME,
# GITHUB_*/RUNNER_* env, and warm module/build caches. A test that is
# green on ubuntu-latest and red in this container (or vice versa) is
# coupled to that host state — it passes because of where it runs, not
# because of what it tests. The known class: code or tests that read
# /etc/tollgate (the testenv tag exists to provision a hermetic config
# dir; a package that forgets it works on a dev box with the dir present
# and dies here), $HOME leftovers, runner-preinstalled binaries on PATH,
# and cache-dependent behavior.
#
# THE RULE: a red lane here is a finding, never something this job papers
# over — no silent skips, no continue-on-error. The ONE carve-out is the
# repo's established known-issues contract (same as the happy-path gate):
# a failure is tolerated ONLY while it is listed in
# tests/contract/clean-container-known-issues.txt with an OPEN issue; the
# gate script prints every tolerated failure loudly, flags stale entries,
# and a build/harness failure is always fatal. Delete an entry when its
# issue closes and the failure is fatal again by itself.
# ngit twin deliberately absent: ngit-ci refuses container: jobs
# (see .ngit/README.md's port table).
clean-container-test:
name: Clean-container module suite (golang:1.26.8-bookworm)
runs-on: ubuntu-latest
timeout-minutes: 45
container:
image: golang:1.26.8-bookworm
steps:
- uses: actions/checkout@v6
# The gate runs the go-test matrix's nine nested modules (same
# intentional omissions — see the NOTE there) plus the main package's
# testenv run, so a divergence between the two lanes is host state,
# never coverage.
- name: Run the module suite in the pristine image
run: bash "$GITHUB_WORKSPACE/.github/scripts/clean-container-gate.sh"

# The customer-facing happy path is gated HERE, on every push and every pull
# request, because this is the workflow that runs on a branch BEFORE anyone
# reviews it: a PR that breaks the happy path is the PR that goes red.
Expand Down
8 changes: 8 additions & 0 deletions .ngit/act/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ jobs:
python3 scripts/changelog-assemble.py check
bash tests/contract/changelog-fragments_test.sh

# Offline only here: ngit jobs get no GITHUB_TOKEN, and unauthenticated
# API checks from a shared runner IP are rate-limit flakiness, not a
# fence. The GitHub twin runs the same checker with --online.
- name: Changelog fragment identity (numbers present, no placeholders)
run: |
bash tests/contract/changelog-fragment-numbers_test.sh
python3 tests/contract/check-changelog-fragments.py

- name: Schema lint
run: node tests/contract/js-schema-lint.mjs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
build-sdk-package artifact (sha256 recorded in the lane README). Reboot
persistence and the payment-flow scenarios (real payment, concurrent
duplicate, kill-recovery — the #502 journal's first on-target exercise) are
the lane's documented next steps.
the lane's documented next steps
([#704](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/704)).
19 changes: 19 additions & 0 deletions changelog.d/767-contract-drift-fences.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- **Four drift fences now guard the contract estate (fragment identity,
toolchain ceiling, SSID matcher table, clean-container suite).** Every
`changelog.d/` fragment must name its own real PR or issue — the number in
the filename, the same number in the body, no link to a foreign PR, no
placeholder text (`check-changelog-fragments.py`; the numberless
vm-campaign entry is repaired as #704's). Every `go.mod` `go` directive
must stay at or below the manifest's pinned toolchain, and workflow
`golang:` container images must match it (`check-toolchain-parity.py`).
The SSID matcher's acceptance table — both brands, bare,
case-insensitive, a double `!` rejected — is pinned by a committed
fixture file shared by the Go test and `check-ssid-naming.sh` (the #618
break class), pinned to what main already satisfies; the
single-`!`-decorated classes and the reader tolerance itself land
separately as #706's reader half (PR #839), freeze-adjudicated on its
own merits. And a
clean-container job runs the module suite in pristine
`golang:1.26.8-bookworm`, where a failure means hidden host-state
coupling — each failure gets its own issue, never a widened fence
([#767](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/767)).
30 changes: 30 additions & 0 deletions changelog.d/827-admin-credential-choice.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
- **A generated admin credential is marked provisional.** A router whose root
password was unset once got a generated credential printed once — easy to
miss, and its only other copy went to `/tmp/tollgate-setup.log`, which is
tmpfs and is truncated at the start of every full-setup run, so an install or
upgrade erased the record. The package now generates nothing at all (see the
0.6.0 no-generated-credentials entry), but the
`/etc/tollgate/admin-credential-provisional` marker is still written by older
installs and still read: it is the signal for the config UI to force a
password change at first login and the reason a later run re-states the
recovery path (`passwd root` over SSH). A locked root account and an
unreadable shadow are deliberately exempt
([#708](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/708)).
- **The admin credential can be chosen at install time, and a generated one
is now marked provisional.** A router whose root password was unset got a
generated credential printed once — easy to miss, and its only other copy
went to `/tmp/tollgate-setup.log`, which is tmpfs and is truncated at the
start of every full-setup run, so an install or upgrade erased the record.
Two changes: the install honours `TOLLGATE_ADMIN_PASSWORD`, so the operator
(or a fleet/CI provisioning script) chooses the credential instead of
receiving one — it is applied and never echoed, and it replaces an existing
credential when supplied; and when a credential IS generated, the package
writes `/etc/tollgate/admin-credential-provisional` (no secret in it), which
is both the signal for the config UI to force a password change at first
login and the reason a later run re-states the recovery path
(`passwd root` over SSH, or reinstall with the variable set). A locked root
account and an unreadable shadow are deliberately exempt; a supplied
password that does not take still fails closed and drops the board
(tracking issue #708)
([#827](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/827)).
([#827](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/827)).
12 changes: 0 additions & 12 deletions changelog.d/admin-credential-choice.changed.md

This file was deleted.

7 changes: 6 additions & 1 deletion hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if git diff --cached --name-only | grep -q '\.go$'; then
python3 tests/contract/check-import-paths.py
fi

if git diff --cached --name-only | grep -qE 'build-inputs\.json|tests/cloud-lab/Dockerfile|\.github/workflows/.*\.yml'; then
if git diff --cached --name-only | grep -qE 'build-inputs\.json|tests/cloud-lab/Dockerfile|\.github/workflows/.*\.yml|\.ngit/act/workflows/.*\.yml|(^|/)go\.mod$'; then
echo "🔍 Checking toolchain parity across build paths..."
python3 tests/contract/check-toolchain-parity.py
fi
Expand Down Expand Up @@ -85,7 +85,12 @@ fi
# malformed one — wrong filename, unknown type, a body that is not the bullet —
# is refused here rather than when a release tries to fold it. The fold tool is
# what writes them into CHANGELOG.md; PRs do not edit the changelog directly.
# The identity fence sits next to the shape fence: a fragment must NAME its own
# change (real PR/issue number in filename and body, no foreign links, no
# placeholders) or the release notes cite the wrong thing. Offline only here;
# CI additionally runs it with --online (every number must exist on GitHub).
if git diff --cached --name-only | grep -qE '^changelog\.d/.*\.md$'; then
echo "🔍 Checking changelog fragments..."
python3 scripts/changelog-assemble.py check
python3 tests/contract/check-changelog-fragments.py
fi
2 changes: 1 addition & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ very different worlds — pick the right one for the job:
|---|---|---|
| **cloud-lab** | Docker-compose integration lab: the real tollgate binary + self-hosted cdk-mintd FakeWallet mints, pytest driver, no routers needed. Payment/fee/rotation/failure lanes, the run-scoped `lab.sh` runner and the shared-host runbook. | [cloud-lab/](cloud-lab/), start with its [README](cloud-lab/README.md) |
| **hardware fleet** | The pytest suite in this directory, against real routers over SSH/Wi-Fi (flashing, install, e2e payment, teardown). Credentials are env-only — see [`.env.example`](.env.example). | this directory + [flash_routers.py](flash_routers.py) |
| **contract** | Offline invariants: schema lint, build purity, dependency/import checks, mirror sync. | [contract/](contract/) |
| **contract** | Offline invariants: schema lint, build purity, dependency/import checks, mirror sync, changelog-fragment identity, toolchain ceiling, SSID matcher table. | [contract/](contract/) |
| **packaging** | Asserts over built artifacts: portal-bundle contract, artifact contents, nodogsplash dependency. | [packaging/](packaging/) |
| **sim** | Offline renewal-policy simulation with committed results. | [sim/](sim/) |
| **happy-path** | Published-artifact happy-path regression suite. | [happy-path/](happy-path/) |
Expand Down
Loading