ci: colored phase banners + quiet-on-success tofu output

Pretty up the live Actions log without flooding it. Three changes:

1. .github/scripts/ci-fmt.sh — sourceable bash helpers (banner, ok, bad,
   warn, note, quiet_run) using ANSI colors that the Actions UI renders.
2. e2e-go.yml + e2e-ddil.yml — tofu phases switch back to quiet redirect
   into $TOFU_LOG (the previous tee'd version regressed live noise vs
   the original silent redirect). Each phase now prints one colored
   ok/✗ line; on failure the tail of $TOFU_LOG dumps inside a single
   collapsed group, so the live log stays scannable but the failing tail
   is one click away.
3. junit-report render.py — compact summary: short status icons (✅/❌)
   in a single column, no separate err column, single-line failure list
   (truncated to 120 chars) instead of <details> blocks that wrap badly
   on narrow screens.

Per-suite test runs still wrap in ::group:: with an inline cyan banner
so pass output collapses and the suite name is obvious when expanded.
This commit is contained in:
joshsiv-mulga
2026-05-18 17:16:11 +10:00
parent 964f4038e0
commit be966b20c4
4 changed files with 162 additions and 107 deletions
+29 -18
View File
@@ -148,22 +148,28 @@ def main() -> int:
grand_total = sum(f.tests for f in totals_by_file)
grand_fail = sum(f.failures + f.errors for f in totals_by_file)
grand_skip = sum(f.skipped for f in totals_by_file)
grand_pass = grand_total - grand_fail - grand_skip
grand_time = sum(f.time for f in totals_by_file)
verdict = ":white_check_mark: pass" if grand_fail == 0 else f":x: {grand_fail} failed"
summary.write(
f"**{verdict}** &middot; {grand_total} tests &middot; "
f"{grand_skip} skipped &middot; {grand_time:.1f}s total\n\n"
)
if grand_fail == 0:
verdict = f"✅ **pass** — {grand_pass} / {grand_total}"
else:
verdict = f"❌ **{grand_fail} failed** — {grand_pass} / {grand_total} pass"
skip_part = f", {grand_skip} skipped" if grand_skip else ""
summary.write(f"{verdict}{skip_part} &nbsp; · &nbsp; {grand_time:.1f}s\n\n")
summary.write("| Suite | Tests | Pass | Fail | Err | Skip | Time |\n")
summary.write("|---|---:|---:|---:|---:|---:|---:|\n")
# Compact table — single status column, shorter headers. Drops
# separate err column (errors fold into fail count) so narrow
# screens don't wrap.
summary.write("| | Suite | Pass | Fail | Skip | Time |\n")
summary.write("|:-:|---|---:|---:|---:|---:|\n")
for f in totals_by_file:
passed = f.tests - f.failures - f.errors - f.skipped
icon = ":white_check_mark:" if (f.failures + f.errors) == 0 else ":x:"
fails = f.failures + f.errors
passed = f.tests - fails - f.skipped
icon = "✅" if fails == 0 else "❌"
summary.write(
f"| {icon} `{_file_label(f.path)}` | {f.tests} | {passed} | "
f"{f.failures} | {f.errors} | {f.skipped} | {f.time:.1f}s |\n"
f"| {icon} | `{_file_label(f.path)}` | {passed} | {fails} | "
f"{f.skipped} | {f.time:.1f}s |\n"
)
summary.write("\n")
@@ -171,21 +177,26 @@ def main() -> int:
c for f in totals_by_file for c in f.cases if c.status in ("fail", "error")
]
if failed_cases:
summary.write("### Failures\n\n")
summary.write("---\n\n**Failures**\n\n")
for c in failed_cases:
header = f"{c.classname}.{c.name}" if c.classname else c.name
summary.write(f"<details><summary><code>{header}</code></summary>\n\n")
if c.message:
summary.write(f"**{c.message}**\n\n")
if c.detail:
summary.write("```\n" + c.detail + "\n```\n\n")
summary.write("</details>\n\n")
# One line per failure: name + truncated message. Full
# detail lives in the test artifact log; deep nesting and
# long messages wrap badly on narrow screens.
msg_short = (c.message or "").splitlines()[0] if c.message else ""
if len(msg_short) > 120:
msg_short = msg_short[:117] + "…"
line = f"- ❌ `{header}`"
if msg_short:
line += f" — {msg_short}"
summary.write(line + "\n")
# Workflow annotation — surfaces in run header + Files-changed view.
# classname for Go tests is the package path; map to a file when we can.
file_hint = c.classname.replace(".", "/")
msg = c.message or f"{c.status}: {header}"
print(f"::error file={file_hint},title={header}::{_esc(msg)}")
summary.write("\n")
outputs.write(f"failures={grand_fail}\n")
outputs.write(f"total={grand_total}\n")
+63
View File
@@ -0,0 +1,63 @@
# shellcheck shell=bash
# Source from a workflow `run:` block:
# source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
#
# Provides colored banners + ::group::/::endgroup:: wrappers. ANSI escapes
# render in the GitHub Actions live log; the same lines also land in any
# artifact log (tee'd output) without breaking parsers because they only
# affect terminal display.
#
# Functions kept short and unindented on purpose — wide nested output wraps
# badly on narrow terminals.
if [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -t 1 ]]; then
_R=$'\033[0m'
_B=$'\033[1m' # bold
_D=$'\033[2m' # dim
_RED=$'\033[31m'
_GRN=$'\033[32m'
_YEL=$'\033[33m'
_BLU=$'\033[34m'
_CYA=$'\033[36m'
else
_R="" _B="" _D="" _RED="" _GRN="" _YEL="" _BLU="" _CYA=""
fi
# Single-line rule using box-drawing chars — narrow enough that GitHub's
# timestamp prefix + this rule still fit on an 80-col terminal.
# banner LABEL — bold cyan title with surrounding rule. Used at the start
# of an inherently multi-line phase (test run, artifact pull) so the live
# log has an obvious visual marker.
banner() {
printf '\n%s%s━━ %s ━━%s\n' "$_B" "$_CYA" "$*" "$_R"
}
# Status indicators. Single char prefix only — keeps width on narrow logs.
ok() { printf '%s✓%s %s\n' "$_GRN" "$_R" "$*"; }
bad() { printf '%s✗%s %s\n' "$_RED" "$_R" "$*"; }
warn() { printf '%s!%s %s\n' "$_YEL" "$_R" "$*"; }
note() { printf '%s·%s %s\n' "$_D" "$_R" "$*"; }
# group_open LABEL / group_close — thin wrapper around GitHub's collapse
# markers. No extra body printed; banner() is the visible inside-group
# heading when callers want one.
group_open() { echo "::group::$*"; }
group_close() { echo "::endgroup::"; }
# quiet_run LABEL LOG_FILE -- CMD ARGS …
# Run CMD redirected to LOG_FILE (append). Print one colored ok/✗ line.
# On failure, dump the last 80 lines of LOG_FILE inside a collapsed group
# so the failing tail lands in the UI without surfacing the whole apply.
quiet_run() {
local label="$1" log="$2"; shift 2
if "$@" >> "$log" 2>&1; then
ok "$label"
else
local rc=$?
bad "$label (rc=$rc) — last 80 lines of $(basename "$log"):"
group_open "tail $(basename "$log")"
tail -n 80 "$log"
group_close
return "$rc"
fi
}
+21 -33
View File
@@ -88,23 +88,15 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
export TF_VAR_git_branch="${GIT_BRANCH}"
# Each phase wrapped in ::group:: so the Actions UI collapses the
# noisy tofu output by default but the human can expand the
# failing phase without downloading $TOFU_LOG.
run() {
local label="$1"; shift
echo "::group::${label}"
"$@" 2>&1 | tee -a "$TOFU_LOG"
local rc=${PIPESTATUS[0]}
echo "::endgroup::"
return "$rc"
}
run "tofu init" tofu init -input=false
run "tofu workspace select" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
run "tofu destroy (pre-clean)" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
run "scrub-env (pre-clean)" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
run "tofu apply" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "VMs provisioned"
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
@@ -163,7 +155,9 @@ jobs:
# plus machine-readable jsonfile + JUnit XML artifacts. The JUnit
# file feeds the junit-report composite action below so failures
# become summary table rows + workflow annotations.
echo "::group::gotestsum scenarios"
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
banner "DDIL scenarios"
DDIL_NODES="${NODE_IPS}" \
DDIL_SSH_USER="${SSH_USER}" \
DDIL_SSH_KEY="${SSH_KEY}" \
@@ -177,7 +171,7 @@ jobs:
--format testname \
-- -tags=e2e -timeout 45m ./tests/e2e/ddil/scenarios/...
rc=$?
echo "::endgroup::"
if [[ $rc -eq 0 ]]; then ok "all scenarios passed"; else bad "scenarios failed (rc=$rc)"; fi
exit $rc
- name: Publish test summary
@@ -203,16 +197,15 @@ jobs:
LOG_DIR="${RUNNER_TEMP}/ddil-results/cluster-logs"
mkdir -p "${LOG_DIR}"
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
mapfile -t NODE_IPS < <(echo "$INVENTORY" | jq -r '.nodes[].wan_ip')
for ip in "${NODE_IPS[@]}"; do
echo "::group::collect ${ip}"
SSH="ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${ip}"
${SSH} 'sudo journalctl -u "spinifex-*" --no-pager -n 5000' > "${LOG_DIR}/${ip}-spinifex.log" 2>&1
${SSH} 'ps auxw | grep -E "qemu-system|spinifex" | grep -v grep' > "${LOG_DIR}/${ip}-procs.txt" 2>&1
${SSH} 'ls -la /var/lib/spinifex 2>/dev/null; ls -la /var/log/spinifex 2>/dev/null' > "${LOG_DIR}/${ip}-state.txt" 2>&1
echo " spinifex.log: $(wc -l < "${LOG_DIR}/${ip}-spinifex.log") lines"
echo " procs.txt: $(wc -l < "${LOG_DIR}/${ip}-procs.txt") lines"
echo "::endgroup::"
ok "$ip: $(wc -l < "${LOG_DIR}/${ip}-spinifex.log") journal lines, $(wc -l < "${LOG_DIR}/${ip}-procs.txt") procs"
done
- name: Upload DDIL Results
@@ -228,14 +221,9 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
run() {
local label="$1"; shift
echo "::group::${label}"
"$@" 2>&1 | tee -a "$TOFU_LOG"
local rc=${PIPESTATUS[0]}
echo "::endgroup::"
return "$rc"
}
run "tofu workspace select" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
run "tofu destroy" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
run "scrub-env" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
ok "teardown complete"
+49 -56
View File
@@ -85,23 +85,15 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
export TF_VAR_git_branch="${GIT_BRANCH}"
# Each phase wrapped in ::group:: so the Actions UI collapses
# the noisy tofu output by default but the human can expand the
# failing phase without downloading $TOFU_LOG.
run() {
local label="$1"; shift
echo "::group::${label}"
"$@" 2>&1 | tee -a "$TOFU_LOG"
local rc=${PIPESTATUS[0]}
echo "::endgroup::"
return "$rc"
}
run "tofu init" tofu init -input=false
run "tofu workspace select" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
run "tofu destroy (pre-clean)" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
run "scrub-env (pre-clean)" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
run "tofu apply" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "VM provisioned"
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
@@ -127,13 +119,18 @@ jobs:
working-directory: spinifex/tests/e2e
run: |
set -euo pipefail
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
# SUITES is in make-target form (e.g. "e2e-cert e2e-lb"); strip the
# "e2e-" prefix to get package directory names for `go test -c`.
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g')
echo "::group::go test -c (build ${SUITES_BUILD// /, })"
banner "build ${SUITES_BUILD// /, }"
group_open "go test -c"
make e2e-build SUITES_BUILD="${SUITES_BUILD}"
ls -lh _bin/
echo "::endgroup::"
group_close
for f in _bin/*.test; do
ok "$(basename "$f") $(stat -c '%s' "$f" | numfmt --to=iec)"
done
- name: Install go-junit-report on runner
run: |
@@ -145,15 +142,13 @@ jobs:
- name: Ship test binaries to VM
run: |
set -euo pipefail
echo "::group::tar-over-ssh: prep remote dir + stream binaries"
ssh -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
tf-user@${{ steps.vm.outputs.wan_ip }} \
"rm -rf \$HOME/spinifex-e2e-bin && mkdir -p \$HOME/spinifex-e2e-bin"
tar -C spinifex/tests/e2e/_bin -cf - . \
| ssh -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
tf-user@${{ steps.vm.outputs.wan_ip }} \
"tar -xf - -C \$HOME/spinifex-e2e-bin"
echo "::endgroup::"
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
ssh "${SSH_OPTS[@]}" "$VM" "rm -rf \$HOME/spinifex-e2e-bin && mkdir -p \$HOME/spinifex-e2e-bin"
tar -C spinifex/tests/e2e/_bin -cf - . | ssh "${SSH_OPTS[@]}" "$VM" "tar -xf - -C \$HOME/spinifex-e2e-bin"
ok "shipped binaries to $VM"
- name: Run Go E2E suites
id: e2e
@@ -161,17 +156,19 @@ jobs:
set +e
# SUITES are make-target names ("e2e-cert" → cert.test). Each
# pre-built binary is self-contained (testdata //go:embed'd) so the
# VM needs no go toolchain, no source tree, no gotestsum.
# Each suite is wrapped in its own ::group:: so the Actions UI
# gives the human one collapsible block per suite.
# VM needs no go toolchain, no source tree, no gotestsum. Each
# suite gets its own ::group:: + cyan banner inside so the Actions
# UI collapses suite output by default; expand only what failed.
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g')
ssh -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
tf-user@${{ steps.vm.outputs.wan_ip }} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; \
cd \$HOME/spinifex-e2e-bin && \
cd \$HOME/spinifex-e2e-bin; \
R=\$'\033[0m' B=\$'\033[1m' C=\$'\033[36m' G=\$'\033[32m' RED=\$'\033[31m'; \
FAIL=0; \
for SUITE in ${SUITES_RUN}; do \
echo \"::group::suite \$SUITE\"; \
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m \
@@ -179,9 +176,12 @@ jobs:
rc=\${PIPESTATUS[0]}; \
echo \"::endgroup::\"; \
if [ \$rc -ne 0 ]; then \
printf '%s✗%s suite %s failed (exit %d)\n' \"\$RED\" \"\$R\" \"\$SUITE\" \"\$rc\"; \
echo \"::error::suite \$SUITE failed (exit \$rc)\"; \
echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; \
FAIL=1; \
else \
printf '%s✓%s suite %s passed\n' \"\$G\" \"\$R\" \"\$SUITE\"; \
fi; \
done; \
exit \$FAIL"
@@ -192,22 +192,20 @@ jobs:
- name: Collect artifacts from VM
if: always()
run: |
set +e
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
mkdir -p "${ARTIFACT_DIR}"
echo "::group::tar-over-ssh: pull artifact dir"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
# `|| true` so a missing dir on an early failure doesn't mask the
# original error.
ssh -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
tf-user@${{ steps.vm.outputs.wan_ip }} \
"tar -C '${ARTIFACT_DIR}' -cf - . 2>/dev/null || true" \
| tar -C "${ARTIFACT_DIR}" -xf - || true
echo "::endgroup::"
echo "::group::journalctl snapshot (spinifex-*)"
ssh -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
tf-user@${{ steps.vm.outputs.wan_ip }} \
'sudo journalctl -u "spinifex-*" --no-pager --since "60 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal.log" || true
echo "wrote ${ARTIFACT_DIR}/spinifex-journal.log ($(wc -l < "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || echo 0) lines)"
echo "::endgroup::"
ssh "${SSH_OPTS[@]}" "$VM" "tar -C '${ARTIFACT_DIR}' -cf - . 2>/dev/null || true" \
| tar -C "${ARTIFACT_DIR}" -xf - 2>/dev/null
ok "artifacts pulled to ${ARTIFACT_DIR}"
ssh "${SSH_OPTS[@]}" "$VM" 'sudo journalctl -u "spinifex-*" --no-pager --since "60 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null
ok "journal: $(wc -l < "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || echo 0) lines"
- name: Convert test logs to JUnit XML
if: always()
@@ -243,17 +241,12 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
run() {
local label="$1"; shift
echo "::group::${label}"
"$@" 2>&1 | tee -a "$TOFU_LOG"
local rc=${PIPESTATUS[0]}
echo "::endgroup::"
return "$rc"
}
run "tofu workspace select" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
run "tofu destroy" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
run "scrub-env" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
ok "teardown complete"
- name: Fail job if any suite failed
if: always()