PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s
Three independent CI/repo-hygiene fixes swept together; disjoint file sets. fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill - New decision record `testing.e2e-cleanup-scope-by-pid`. - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the #586 root cause was a delegation gap, not agent error). - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and deliberately does NOT adopt it — its contract is to hand a running instance back to its caller, so an EXIT trap would kill the server the instant the launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded. - Instead it gains what actually prevents the incident: an lsof pre-flight that NAMES a foreign listener's PID rather than letting Kestrel fail its bind and surface as "process N exited before becoming ready". - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409 independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409 and died against a foreign holder — i.e. the documented escape hatch was a dead end that led straight back to the confusion behind the pattern kill. fixes #594 — ci-image-pin accepts any hex length - Length is a separate invariant from correctness: the resolve/staleness checks compare resolved shas, so an 8-char pin of the right commit passes green while matching NO registry tag, and all five container: jobs then die at image-pull with `manifest unknown` (reads like a registry outage). - Guard fails at the gate and prints the exact tag to use. Verified against doctored pins: 7 green; 6/8/10 red. - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may widen an ambiguous abbreviation, demanding a pin ci-image.yml can never publish. Escape hatch documented inline. - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE". - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`. fixes #485 — .gitignore `core` silently ignored `*/Core/` files - A bare `core` matched any path component named `core`; case-insensitively on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files were dropped by `git add -A` while tracked ones stayed fine — a clean local build and a CI checkout that fails to compile. - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*` would re-introduce the same silent-exclusion class this fixes). - Verified by diffing the full ignored-file set before/after: identical, and the three real Core/ dirs are trackable without -f. Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/ workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md. Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP recovery record) and the ci-image.yml `--short=7` publisher-side fix, which cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected` at this commit and reds the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
7.1 KiB
Bash
Executable File
147 lines
7.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/e2e-local.sh — launch a local ErsatzTV instance for live E2E verification.
|
|
#
|
|
# See docs/e2e-local.md for the full recipe and the "why" behind each step. This script assumes
|
|
# `dotnet build ErsatzTV.sln` and (if the SPA changed) `cd web && npm run build` have ALREADY been
|
|
# run — it only does the wwwroot copy + launch + ready-wait, since rebuilding on every invocation
|
|
# is slow and this is meant to be re-run often during a debugging session.
|
|
#
|
|
# Usage:
|
|
# scripts/e2e-local.sh [CONFIG_DIR]
|
|
#
|
|
# CONFIG_DIR defaults to a fresh `mktemp -d` if omitted. NEVER reuse a config dir across runs.
|
|
#
|
|
# On success, prints:
|
|
# PID=<pid>
|
|
# PORT=<port>
|
|
# CONFIG_DIR=<dir>
|
|
# LOG=<log file path>
|
|
# and exits 0, leaving the server RUNNING in the background.
|
|
#
|
|
# THE CALLER OWNS THE PRINTED PID. Stop the server with `kill "$PID"` using the PID printed above,
|
|
# and free the port before starting another run.
|
|
#
|
|
# NEVER `pkill -f "dotnet ErsatzTV.dll"` (ersatztv#586, `testing.e2e-cleanup-scope-by-pid`). This
|
|
# machine is shared by parallel sessions running this same binary, and a pattern kill reaps all of
|
|
# them — silently truncating another run's output into plausible-but-wrong data rather than failing
|
|
# loudly. Choosing a different port does NOT make a pattern kill safe: it matches on the command
|
|
# line, not the port. Kill the PID you started; if some other process holds the port, report it and
|
|
# move to `ETV_UI_PORT=<other>` (the pre-flight below prints the offending PID for you, and also
|
|
# moves the streaming listener — see the ETV_STREAMING_PORT note below, which is why that actually
|
|
# works here but not when you launch the DLL by hand).
|
|
#
|
|
# This script deliberately does NOT trap-and-kill on exit, unlike scripts/e2e-ui.sh: its contract is
|
|
# to hand a running instance back to its caller, so an EXIT trap would kill the server the moment the
|
|
# launcher returned. e2e-ui.sh is the lifecycle OWNER and traps; this is the launcher and does not.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
# ETV_BUILD_CONFIG selects which build output to launch (Debug for local dev; the CI functional-E2E
|
|
# job builds Release). Must match the `dotnet build --configuration` you ran beforehand.
|
|
BUILD_CONFIG="${ETV_BUILD_CONFIG:-Debug}"
|
|
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/$BUILD_CONFIG/net10.0"
|
|
PORT="${ETV_UI_PORT:-8409}"
|
|
# The app binds TWO listeners: Program.cs does `ListenAnyIP(UiPort)` and, when they differ,
|
|
# `ListenAnyIP(StreamingPort)` — and SystemEnvironment.cs defaults StreamingPort to 8409
|
|
# INDEPENDENTLY of ETV_UI_PORT. So `ETV_UI_PORT=8420` alone still binds 8409 and dies at startup if
|
|
# another session holds it, which is the exact dead end that produced the #586 pattern-kill. Default
|
|
# the streaming port to whatever port this run was given so "move to a free port" actually works; an
|
|
# explicit ETV_STREAMING_PORT still wins. CI sets ETV_UI_PORT=8409, so this is a no-op there.
|
|
export ETV_STREAMING_PORT="${ETV_STREAMING_PORT:-$PORT}"
|
|
|
|
# Reject a non-numeric port rather than let it fail silently in the WORST possible way: `lsof -ti :abc`
|
|
# fails a service-name lookup so the pre-flight below skips it, and SystemEnvironment.cs's int.TryParse
|
|
# then falls back to 8409 — so a typo'd port makes the app bind the very port the pre-flight just
|
|
# certified as irrelevant, and the run dies against the foreign holder with exactly the confusing
|
|
# framing this script exists to prevent.
|
|
for port_var in ETV_UI_PORT:"$PORT" ETV_STREAMING_PORT:"$ETV_STREAMING_PORT"; do
|
|
case "${port_var#*:}" in
|
|
''|*[!0-9]*)
|
|
echo "error: ${port_var%%:*} must be a number, got '${port_var#*:}'. A non-numeric port is" >&2
|
|
echo " silently ignored by the app, which then falls back to binding 8409." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
READY_LINE="Done migrating search index"
|
|
TIMEOUT_SECS=120
|
|
|
|
# Pre-flight (before mktemp, so a refused run leaks no config dir): name the process holding the
|
|
# port rather than letting this fail at startup with `process N exited before becoming ready` and a
|
|
# log tail — a framing that reads like a broken build instead of "something else is already
|
|
# listening" (ersatztv#586). A foreign listener is REPORTED, never reaped: on a shared machine it is
|
|
# very likely another session's harness mid-run. Both bound ports are probed, since a free UI port
|
|
# with a busy streaming port fails just as hard. `command -v` guard: lsof is absent from some
|
|
# containers, and this diagnostic must never itself be the reason a run fails.
|
|
if command -v lsof >/dev/null 2>&1; then
|
|
for probe_port in $(printf '%s\n%s\n' "$PORT" "$ETV_STREAMING_PORT" | sort -u); do
|
|
if lsof -ti :"$probe_port" >/dev/null 2>&1; then
|
|
echo "error: port $probe_port is already in use by PID(s): $(lsof -ti :"$probe_port" | tr '\n' ' ')" >&2
|
|
echo " That process is NOT yours. Do NOT 'pkill -f \"dotnet ErsatzTV.dll\"' — on this shared" >&2
|
|
echo " machine that reaps other sessions' servers mid-run (ersatztv#586)." >&2
|
|
echo " Re-run on a free port instead. Set BOTH, or the app still binds 8409:" >&2
|
|
echo " ETV_UI_PORT=8420 ETV_STREAMING_PORT=8420 $0${1:+ \"$1\"}" >&2
|
|
echo " (this script defaults ETV_STREAMING_PORT to ETV_UI_PORT, so ETV_UI_PORT=8420 alone" >&2
|
|
echo " is enough when you invoke it directly — set both when launching the app by hand.)" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
fi
|
|
|
|
CONFIG_DIR="${1:-$(mktemp -d)}"
|
|
mkdir -p "$CONFIG_DIR"
|
|
|
|
if [ ! -d "$BUILD_DIR" ]; then
|
|
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln${BUILD_CONFIG:+ --configuration $BUILD_CONFIG}' first" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ ! -d "$REPO_ROOT/ErsatzTV/wwwroot/app" ]; then
|
|
echo "warning: $REPO_ROOT/ErsatzTV/wwwroot/app does not exist — the SPA hasn't been built" \
|
|
"('cd web && npm run build'). The /app UI will 404 until it exists AND the server is" \
|
|
"(re)started after copying it in." >&2
|
|
fi
|
|
|
|
echo "Copying wwwroot into build output (static middleware resolves its file root at startup;" \
|
|
"a running process will never see files added later)..."
|
|
# rm first: with an existing destination dir, `cp -R src dst` copies INTO it (dst/wwwroot/...),
|
|
# silently leaving a previous run's stale assets in place.
|
|
rm -rf "$BUILD_DIR/wwwroot"
|
|
cp -R "$REPO_ROOT/ErsatzTV/wwwroot" "$BUILD_DIR/wwwroot"
|
|
|
|
LOG_FILE="$(mktemp)"
|
|
echo "Launching dotnet ErsatzTV.dll (log: $LOG_FILE, config: $CONFIG_DIR, port: $PORT)..."
|
|
|
|
(
|
|
cd "$BUILD_DIR"
|
|
ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" ETV_STREAMING_PORT="$ETV_STREAMING_PORT" dotnet ErsatzTV.dll
|
|
) >"$LOG_FILE" 2>&1 &
|
|
PID=$!
|
|
|
|
echo "Waiting up to ${TIMEOUT_SECS}s for '$READY_LINE'..."
|
|
elapsed=0
|
|
until grep -q "$READY_LINE" "$LOG_FILE" 2>/dev/null; do
|
|
if ! kill -0 "$PID" 2>/dev/null; then
|
|
echo "error: process $PID exited before becoming ready. Log tail:" >&2
|
|
tail -n 40 "$LOG_FILE" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$elapsed" -ge "$TIMEOUT_SECS" ]; then
|
|
echo "error: timed out after ${TIMEOUT_SECS}s waiting for readiness. Log tail:" >&2
|
|
tail -n 40 "$LOG_FILE" >&2
|
|
kill "$PID" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
sleep 1
|
|
elapsed=$((elapsed + 1))
|
|
done
|
|
|
|
echo "Server ready."
|
|
echo "PID=$PID"
|
|
echo "PORT=$PORT"
|
|
echo "CONFIG_DIR=$CONFIG_DIR"
|
|
echo "LOG=$LOG_FILE"
|