feat(02-05): add the repeatable Phase 2 vet, race and PHP gate

- Check root and fonoteka.go with vet, test and race plus TestParitySynthetic
- Audit the 154-route corpus and smoke parity:record/replay against loopback
- Provision a disposable MariaDB, pin PHP to 127.0.0.1:8423, and self-replay seed, routes and clients

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 14:09:16 +02:00
parent 5ed920b7b1
commit a296e98d8e

368
scripts/check-phase2.sh Executable file
View File

@@ -0,0 +1,368 @@
#!/usr/bin/env bash
# Repeatable Phase 2 verification: root and app vet/test/race, corpus audit,
# CLI synthetic smoke, and a disposable MariaDB-backed PHP self-replay.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$(cd "$ROOT/../fonoteka.go" && pwd)"
PHP_ROOT="${PHP_ROOT:-/media/nvme/dev/golem15/fonoteka}"
PHP_TARGET="http://127.0.0.1:8423"
ROUTES_PHP="$PHP_ROOT/plugins/golem15/fonoteka/routes.php"
if [[ "${1:-}" != "--fresh-php" ]]; then
echo "usage: $0 --fresh-php" >&2
exit 2
fi
if [[ -n "${PHP_PARITY_TARGET:-}" ]]; then
echo "refuse: caller-supplied PHP_PARITY_TARGET is not permitted" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "refuse: docker is required for --fresh-php" >&2
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "refuse: docker daemon is not available" >&2
exit 1
fi
if [[ ! -f "$PHP_ROOT/artisan" ]]; then
echo "refuse: PHP checkout missing artisan at $PHP_ROOT" >&2
exit 1
fi
if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8423'; then
echo "refuse: 127.0.0.1:8423 is already in use; the gate starts its own PHP child" >&2
exit 1
fi
run_module() {
local name="$1"
local dir="$2"
echo "==> ${name} (${dir})"
(
cd "$dir"
go vet ./...
go test ./...
go test -race ./...
)
}
redact() {
sed -E \
-e 's/(client_secret=)[^[:space:]]+/\1[redacted]/g' \
-e 's/(ADMIN_PASSWORD=)[^[:space:]]+/\1[redacted]/g' \
-e 's/(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/[jwt-redacted]/g' \
-e 's/(inv_[A-Za-z0-9]{8,})/[inv-redacted]/g'
}
echo "==> framework root"
run_module "root" "$ROOT"
echo "==> app module"
run_module "app" "$APP"
echo "==> TestParitySynthetic (testcontainers Postgres)"
(
cd "$APP"
go test ./parity -run TestParitySynthetic -count=1
)
echo "==> corpus audit"
CORPUS_ARGS=(
--manifest "$APP/parity/manifest.yaml"
--require-recorded
--require-clients
--check-secrets
)
if [[ -f "$ROUTES_PHP" ]]; then
CORPUS_ARGS+=(--routes "$ROUTES_PHP")
fi
(
cd "$APP"
go run ./parity/check_corpus.go "${CORPUS_ARGS[@]}"
)
echo "==> CLI synthetic record/replay smoke"
SMOKE_DIR="$(mktemp -d /tmp/summercms-parity-smoke-XXXXXX)"
SMOKE_PORT=""
SMOKE_PID=""
cleanup_smoke() {
if [[ -n "${SMOKE_PID:-}" ]]; then
kill "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
fi
rm -rf "$SMOKE_DIR"
}
python3 - "$SMOKE_DIR" <<'PY' &
import http.server, socketserver, sys, pathlib
d = pathlib.Path(sys.argv[1])
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"data":"ok"}')
def log_message(self, *args):
pass
with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd:
port = httpd.server_address[1]
(d / "port").write_text(str(port))
httpd.serve_forever()
PY
SMOKE_PID=$!
for _ in $(seq 1 50); do
if [[ -f "$SMOKE_DIR/port" ]]; then
SMOKE_PORT="$(cat "$SMOKE_DIR/port")"
break
fi
sleep 0.1
done
if [[ -z "$SMOKE_PORT" ]]; then
cleanup_smoke
echo "refuse: synthetic smoke server did not start" >&2
exit 1
fi
(
cd "$ROOT"
go build -o "$SMOKE_DIR/summer" ./cmd/summer
"$SMOKE_DIR/summer" parity:record \
--spec tide/testdata/one-route-spec.yaml \
--target "http://127.0.0.1:${SMOKE_PORT}" \
--output "$SMOKE_DIR/sample.yaml"
"$SMOKE_DIR/summer" parity:replay \
--fixtures "$SMOKE_DIR/sample.yaml" \
--target "http://127.0.0.1:${SMOKE_PORT}"
)
cleanup_smoke
echo "==> fresh PHP self-replay on disposable MariaDB"
RUN_ID="$(date +%s)_$$"
DB_NAME="fonoteka_parity_${RUN_ID}"
DB_USER="parity_${RUN_ID}"
DB_PASS="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
ADMIN_PASS="$(python3 -c 'import secrets; print(secrets.token_urlsafe(18))')"
CONTAINER="fonoteka-parity-${RUN_ID}"
VARS_DIR="$(mktemp -d /tmp/summercms-parity-vars-XXXXXX)"
VARS_FILE="$VARS_DIR/vars.yaml"
PHP_PID=""
SUMMER_BIN="$VARS_DIR/summer"
cleanup_php() {
if [[ -n "${PHP_PID:-}" ]]; then
kill "$PHP_PID" 2>/dev/null || true
wait "$PHP_PID" 2>/dev/null || true
fi
if [[ -n "${CONTAINER:-}" ]]; then
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
rm -rf "$VARS_DIR"
}
trap cleanup_php EXIT
echo "==> mariadb container $CONTAINER (loopback ephemeral port, db $DB_NAME)"
docker run -d --name "$CONTAINER" \
-e MYSQL_ROOT_PASSWORD="$DB_PASS" \
-e MYSQL_USER="$DB_USER" \
-e MYSQL_PASSWORD="$DB_PASS" \
-e MYSQL_DATABASE="$DB_NAME" \
-p 127.0.0.1:0:3306 \
mariadb:11 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci >/dev/null
DB_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "3306/tcp") 0).HostPort}}' "$CONTAINER")"
if [[ -z "$DB_PORT" ]]; then
echo "refuse: could not discover MariaDB host port" >&2
exit 1
fi
echo "==> mariadb listening on 127.0.0.1:${DB_PORT}"
ready=0
for _ in $(seq 1 90); do
if docker exec "$CONTAINER" mariadbadmin ping -uroot -p"$DB_PASS" --silent >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
if [[ "$ready" -ne 1 ]]; then
echo "refuse: MariaDB did not become ready" >&2
exit 1
fi
php_env() {
# Process-local DB env wins over the PHP checkout .env. Never export the
# developer database name or checkout credentials.
export DB_CONNECTION=mysql
export DB_HOST=127.0.0.1
export DB_PORT="$DB_PORT"
export DB_DATABASE="$DB_NAME"
export DB_USERNAME="$DB_USER"
export DB_PASSWORD="$DB_PASS"
export CACHE_DRIVER=array
export SESSION_DRIVER=array
export QUEUE_CONNECTION=sync
export SCOUT_DRIVER=null
export MAIL_MAILER=array
export LOG_CHANNEL=stderr
export BROADCAST_DRIVER=log
export BROADCAST_ENABLED=false
export DISCOGS_TOKEN=
export APP_URL="$PHP_TARGET"
export ADMIN_EMAIL=admin@parity.test
export ADMIN_LOGIN=admin
export ADMIN_PASSWORD="$ADMIN_PASS"
export ADMIN_FIRST_NAME=Parity
export ADMIN_LAST_NAME=Admin
}
php_artisan() {
php_env
(cd "$PHP_ROOT" && php artisan "$@")
}
php_env
php_artisan config:clear >/dev/null
echo "==> preflight: artisan DB identity and empty schema"
ACTIVE_DB="$(php_artisan tinker --no-interaction --execute='echo DB::connection()->getDatabaseName();' | tail -n 1 | tr -d '\r')"
if [[ "$ACTIVE_DB" != "$DB_NAME" ]]; then
echo "refuse: artisan database is '$ACTIVE_DB', want '$DB_NAME'" >&2
exit 1
fi
TABLES="$(php_artisan tinker --no-interaction --execute='echo count(DB::select("SHOW TABLES"));' | tail -n 1 | tr -d '\r')"
if [[ "$TABLES" != "0" ]]; then
echo "refuse: expected zero application tables before migrations, got $TABLES" >&2
exit 1
fi
echo "==> preflight ok: database=$ACTIVE_DB tables=$TABLES"
echo "==> php artisan winter:up (admin bootstrap via ADMIN_* env)"
php_artisan winter:up >/dev/null
echo "==> php artisan fonoteka:oauth-client (secrets redacted)"
set +e
OAUTH_OUT="$(php_artisan fonoteka:oauth-client "Parity MCP" \
--redirect-uri=http://127.0.0.1:8422/oauth/callback \
--scope=read --scope=write --scope=ai 2>&1)"
OAUTH_RC=$?
set -e
echo "$OAUTH_OUT" | redact
if [[ "$OAUTH_RC" -ne 0 ]]; then
echo "refuse: fonoteka:oauth-client failed" >&2
exit 1
fi
CLIENT_ID="$(printf '%s\n' "$OAUTH_OUT" | sed -n 's/^client_id=//p' | head -1)"
CLIENT_SECRET="$(printf '%s\n' "$OAUTH_OUT" | sed -n 's/^client_secret=//p' | head -1)"
if [[ -z "$CLIENT_ID" || -z "$CLIENT_SECRET" ]]; then
echo "refuse: oauth-client did not print client_id/client_secret" >&2
exit 1
fi
umask 077
python3 - "$VARS_FILE" "$CLIENT_ID" "$CLIENT_SECRET" <<'PY'
import json, pathlib, sys
path, cid, secret = sys.argv[1], sys.argv[2], sys.argv[3]
pathlib.Path(path).write_text(
f"{json.dumps('oauth:artisan-client')}: {json.dumps(cid)}\n"
f"{json.dumps('oauth:artisan-secret')}: {json.dumps(secret)}\n"
)
PY
chmod 600 "$VARS_FILE"
unset CLIENT_SECRET OAUTH_OUT
echo "==> php artisan serve --host=127.0.0.1 --port=8423"
php_env
php "$PHP_ROOT/artisan" serve --host=127.0.0.1 --port=8423 >/dev/null 2>&1 &
PHP_PID=$!
ready=0
for _ in $(seq 1 60); do
if python3 - "$PHP_TARGET" <<'PY' >/dev/null 2>&1
import sys, urllib.request
urllib.request.urlopen(sys.argv[1], timeout=1)
PY
then
ready=1
break
fi
sleep 0.25
done
if [[ "$ready" -ne 1 ]]; then
echo "refuse: PHP child did not become ready on $PHP_TARGET" >&2
exit 1
fi
(
cd "$ROOT"
go build -o "$SUMMER_BIN" ./cmd/summer
)
replay_seed() {
"$SUMMER_BIN" parity:replay \
--fixtures "$APP/parity/fixtures/seed/bootstrap.yaml" \
--target "$PHP_TARGET" \
--vars "$VARS_FILE"
}
echo "==> seed replay"
replay_seed | redact
echo "==> 154-route self-replay --self-check --require-recorded"
"$SUMMER_BIN" parity:replay \
--manifest "$APP/parity/manifest.yaml" \
--fixtures "$APP/parity/fixtures" \
--target "$PHP_TARGET" \
--vars "$VARS_FILE" \
--self-check true \
--require-recorded true | redact
echo "==> reset schema for client flows (fresh seed, not post-route mutation)"
php_artisan tinker --no-interaction --execute='foreach (DB::select("SHOW TABLES") as $row) { $name = array_values((array)$row)[0]; DB::statement("SET FOREIGN_KEY_CHECKS=0"); DB::statement("DROP TABLE `$name`"); DB::statement("SET FOREIGN_KEY_CHECKS=1"); }' >/dev/null
TABLES="$(php_artisan tinker --no-interaction --execute='echo count(DB::select("SHOW TABLES"));' | tail -n 1 | tr -d '\r')"
if [[ "$TABLES" != "0" ]]; then
echo "refuse: client-cycle reset left $TABLES tables" >&2
exit 1
fi
php_artisan winter:up >/dev/null
set +e
php_artisan fonoteka:oauth-client "Parity MCP" \
--redirect-uri=http://127.0.0.1:8422/oauth/callback \
--scope=read --scope=write --scope=ai >/dev/null 2>&1
OAUTH_RC=$?
set -e
if [[ "$OAUTH_RC" -ne 0 ]]; then
echo "refuse: fonoteka:oauth-client failed on client-cycle reset" >&2
exit 1
fi
: >"$VARS_FILE"
chmod 600 "$VARS_FILE"
echo "==> client seed replay"
replay_seed | redact
echo "==> nuxt-browse replay"
"$SUMMER_BIN" parity:replay \
--fixtures "$APP/parity/fixtures/nuxt/nuxt-browse.yaml" \
--target "$PHP_TARGET" \
--vars "$VARS_FILE" | redact
echo "==> mcp-tools replay"
"$SUMMER_BIN" parity:replay \
--fixtures "$APP/parity/fixtures/mcp/mcp-tools.yaml" \
--target "$PHP_TARGET" \
--vars "$VARS_FILE" | redact
if [[ -f /tmp/summercms-parity/pkce.vars ]]; then
cat /tmp/summercms-parity/pkce.vars >>"$VARS_FILE"
chmod 600 "$VARS_FILE"
fi
echo "==> mcp-oauth replay"
"$SUMMER_BIN" parity:replay \
--fixtures "$APP/parity/fixtures/mcp/mcp-oauth.yaml" \
--target "$PHP_TARGET" \
--vars "$VARS_FILE" | redact
echo "phase2 check passed"