#!/usr/bin/env bash # Local CI pipeline. # # This script is the single definition of "does it pass". A hosted CI runner # later just calls it; nothing here assumes GitHub, Gitea or anything else. # # ./scripts/ci.sh full run, including the container smoke test # ./scripts/ci.sh --fast static checks only (what the pre-push hook runs) # # The full run uses its own database (szabadpalya_ci) on the development # Postgres, so it never touches the data you are working with. set -uo pipefail cd "$(dirname "$0")/.." FAST=0 [ "${1:-}" = "--fast" ] && FAST=1 DB_CONTAINER=szabadpalya-db DB_USER=szabadpalya CI_DB=szabadpalya_ci APP_CONTAINER=szabadpalya-ci-app APP_PORT=3210 SICK_CONTAINER=szabadpalya-ci-sick SICK_PORT=3211 IMAGE=szabadpalya:ci SEED_IMAGE=szabadpalya:ci-seed SLUG=rakoczi-teri-kozossegi-kert FAILED=() START=$(date +%s) bold() { printf '\033[1m%s\033[0m\n' "$1"; } pass() { printf ' \033[32m✓\033[0m %s \033[2m(%ss)\033[0m\n' "$1" "$2"; } fail() { printf ' \033[31m✗\033[0m %s \033[2m(%ss)\033[0m\n' "$1" "$2"; FAILED+=("$1"); } # step step() { local name="$1"; shift local t0 t1 out t0=$(date +%s) if out=$("$@" 2>&1); then t1=$(date +%s); pass "$name" "$((t1 - t0))" else t1=$(date +%s); fail "$name" "$((t1 - t0))" printf '%s\n' "$out" | tail -25 | sed 's/^/ /' fi } cleanup() { docker rm -f "$APP_CONTAINER" "$SICK_CONTAINER" >/dev/null 2>&1 || true } trap cleanup EXIT # ---------------------------------------------------------------- static ---- bold "Static checks — backend" # Generated code that nobody verifies is worse than none: it looks like it # describes the schema while quietly describing an older one. This regenerates # from the migrations and the query files and fails if the result differs from # what is committed. sqlc_sync() { ./scripts/sqlc.sh diff; } step "generated queries are current" sqlc_sync go_build() { ./scripts/go.sh build ./...; } go_vet() { ./scripts/go.sh vet ./...; } # gofumpt when it can be fetched, go fmt otherwise. Both report unformatted # files on stdout and say nothing when there is nothing to say -- so empty # output means "clean" ONLY once one of them has actually run. Check that # first: a formatter that could not start (no daemon, no network, a package # that does not compile) also produces no stdout, and reading that as a pass # would turn the silence into a tick. go_format() { local out if ! out=$(./scripts/go.sh run mvdan.cc/gofumpt@latest -l . 2>&1) \ && ! out=$(./scripts/go.sh fmt ./... 2>&1); then echo "the formatter could not run, so nothing was checked:" printf '%s\n' "$out" return 1 fi [ -z "$out" ] || { echo "not gofmt'd:"; printf '%s\n' "$out"; return 1; } } step "go build" go_build step "go vet" go_vet step "gofmt" go_format bold "Static checks — frontend" web_typecheck() { (cd web && npx tsc --noEmit); } web_lint() { (cd web && npx eslint .); } web_build() { (cd web && npx vite build); } step "typecheck" web_typecheck step "lint" web_lint step "vite build" web_build # Project rule: the product is Hungarian, everything else is English. # Guard the code side of that so it does not drift back. check_language() { local allow='Szabadpálya|rendszerbontó|Támogatók|Beleadom|Ft|hu-HU|Europe/Budapest|Belépés|Admin' local hits hits=$(git ls-files '*.ts' '*.tsx' '*.go' '*.css' '*.sql' '*.sh' '*.yaml' 'Dockerfile' \ | xargs grep -nE '^[[:space:]]*(//|#|\*|/\*|///|--)' 2>/dev/null \ | grep -E '[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]' \ | grep -vE "$allow" || true) [ -z "$hits" ] || { printf '%s\n' "$hits"; return 1; } } step "comments are English" check_language # Things that must never be committed. check_secrets() { local bad bad=$(git ls-files | grep -E '(^|/)\.env$|\.env\.(local|production)$|\.pem$' || true) [ -z "$bad" ] || { echo "committed secret-ish files:"; printf '%s\n' "$bad"; return 1; } } step "no secrets committed" check_secrets if [ "$FAST" = "1" ]; then END=$(date +%s) echo if [ ${#FAILED[@]} -eq 0 ]; then bold "fast checks passed in $((END - START))s"; exit 0 else bold "FAILED: ${FAILED[*]}"; exit 1 fi fi # ------------------------------------------------------------- container ---- echo bold "Container smoke test" ensure_db() { docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" >/dev/null 2>&1 || ./scripts/db.sh up >/dev/null docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -tc \ "SELECT 1 FROM pg_database WHERE datname='${CI_DB}'" | grep -q 1 \ || docker exec "$DB_CONTAINER" createdb -U "$DB_USER" "$CI_DB" # A rerun must start from a known schema, not from whatever the last one left. docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \ -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null } step "database ready" ensure_db build_image() { docker build --target runner -t "$IMAGE" .; } step "docker image builds" build_image # The seeder is deliberately not in the shipping image, so CI builds the target # that carries it. Same base, same layers — only the extra binary differs. build_seeder() { docker build --target seeder -t "$SEED_IMAGE" .; } step "seeder image builds" build_seeder # What must NOT be there: a binary that starts by deleting every campaign has no # business in the image that runs in production. check_no_seeder_in_runner() { if docker run --rm --entrypoint sh "$IMAGE" -c 'command -v seed' >/dev/null 2>&1; then echo "the seed binary is present in the runner image"; return 1 fi } step "runner image ships no seeder" check_no_seeder_in_runner # The database publishes on 127.0.0.1 only, so a container cannot reach it by # going out to the host and back the way this used to. Join the network the # database is already on and address it there: one hop shorter, and it does not # care what the host publishes. Its IP rather than its name, because the plain # `docker run` path in db.sh lands on the default bridge, which has no DNS. db_net() { docker inspect -f '{{range $n, $_ := .NetworkSettings.Networks}}{{$n}}{{end}}' "$DB_CONTAINER"; } db_addr() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$DB_CONTAINER"; } # The migrator and the seeder run from the image, so CI exercises the same # binaries production does rather than a host toolchain that may differ. in_image() { local image="$1"; shift docker run --rm --network "$(db_net)" \ -e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@$(db_addr):5432/${CI_DB}" \ -e SESSION_SECRET="ci-only-secret" \ "$image" "$@" } migrate_ci() { in_image "$IMAGE" server -migrate; } seed_ci() { in_image "$SEED_IMAGE" seed; } step "migrations apply" migrate_ci step "migrations are idempotent" migrate_ci step "seed loads" seed_ci boot_app() { docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true docker run -d --name "$APP_CONTAINER" -p "127.0.0.1:${APP_PORT}:3000" \ --network "$(db_net)" \ --health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=3 \ -e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@$(db_addr):5432/${CI_DB}" \ -e SESSION_SECRET="ci-only-secret" \ -e BASE_URL="http://localhost:${APP_PORT}" \ -e ADMIN_EMAILS="admin@example.invalid" \ "$IMAGE" >/dev/null for _ in $(seq 1 45); do curl -sf -o /dev/null "http://localhost:${APP_PORT}/" && return 0 sleep 1 done echo "container never became ready:"; docker logs "$APP_CONTAINER" 2>&1 | tail -20 return 1 } step "container boots and serves" boot_app http_ok() { local path="$1" code code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}${path}") [ "$code" = "200" ] || { echo "$path returned $code"; return 1; } } step "GET /" http_ok / step "GET /kampanyok" http_ok /kampanyok step "GET campaign page" http_ok "/kampanyok/${SLUG}" step "GET og image" http_ok "/kampanyok/${SLUG}/og.png" step "GET /api/campaigns" http_ok /api/campaigns # The share image must be a real PNG of the right size, not an error page that # happens to return 200. check_og_png() { local head size head=$(curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}/og.png" | head -c 8 | od -An -tx1 | tr -d ' \n') [ "$head" = "89504e470d0a1a0a" ] || { echo "og.png is not a PNG (magic: $head)"; return 1; } size=$(curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}/og.png" | wc -c) [ "$size" -gt 10000 ] || { echo "og.png suspiciously small: ${size} bytes"; return 1; } } step "og image is a real PNG" check_og_png # The numbers must come out of the pledge rows, not out of a stored column. check_derived() { local json json=$(curl -s "http://localhost:${APP_PORT}/api/campaigns/${SLUG}") grep -q '"raised":1356000' <<<"$json" || { echo "derived raised amount wrong (expected 1356000)"; return 1; } grep -q '"backers":150' <<<"$json" || { echo "derived backer count wrong"; return 1; } # A tier whose backers reached its inventory limit is what renders "Elfogyott". python3 - "$json" <<'PY' || return 1 import json, sys tiers = json.loads(sys.argv[1])["tiers"] if not any(t.get("inventoryLimit") and t["backers"] >= t["inventoryLimit"] for t in tiers): print("no sold-out tier in the fixture"); raise SystemExit(1) PY } step "totals derived from pledges" check_derived # Per-campaign share tags are the reason the server renders HTML at all. check_og_tags() { local html html=$(curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}") grep -q 'og:title" content="Rákóczi téri közösségi kert' <<<"$html" \ || { echo "campaign-specific og:title missing"; return 1; } grep -q "og:image\" content=\"http://localhost:${APP_PORT}/kampanyok/${SLUG}/og.png\"" <<<"$html" \ || { echo "absolute og:image missing"; return 1; } grep -q '113%' <<<"$html" || { echo "og:description carries no derived progress"; return 1; } } step "per-campaign og tags" check_og_tags # The inlined payload is what saves the first route a round-trip. It must be # present, parseable, and about the campaign that was asked for. check_bootstrap() { curl -s "http://localhost:${APP_PORT}/kampanyok/${SLUG}" \ | python3 -c ' import re, sys, json html = sys.stdin.read() m = re.search(r"", html, re.S) if not m: print("no __DATA__ payload"); raise SystemExit(1) payload = json.loads(m.group(1)) assert payload["route"] == "campaign", payload["route"] assert payload["data"]["slug"] == "'"${SLUG}"'", payload["data"]["slug"] ' } step "bootstrap payload inlined" check_bootstrap # The landing page shows the headline numbers only; the campaigns themselves # live one click away, on /kampanyok. If the list creeps back into the home # payload the page is loading rows it does not render. check_home_has_no_list() { curl -s "http://localhost:${APP_PORT}/" \ | python3 -c ' import re, sys, json html = sys.stdin.read() m = re.search(r"", html, re.S) if not m: print("no __DATA__ payload"); raise SystemExit(1) payload = json.loads(m.group(1)) assert payload["route"] == "home", payload["route"] assert "stats" in payload["data"], "the home payload lost its stats" assert "campaigns" not in payload["data"], "the home payload still carries the campaign list" ' } step "landing page carries no campaign list" check_home_has_no_list check_health_ok() { local body code body=$(curl -s -w '\n%{http_code}' "http://localhost:${APP_PORT}/api/health") code=$(tail -1 <<<"$body") [ "$code" = "200" ] || { echo "/api/health returned $code"; return 1; } grep -q '"database":"ok"' <<<"$body" || { echo "unexpected body: $body"; return 1; } } step "health endpoint reports ok" check_health_ok # Docker must see the container as healthy, not merely running. check_docker_health() { local status for _ in $(seq 1 30); do status=$(docker inspect --format '{{.State.Health.Status}}' "$APP_CONTAINER" 2>/dev/null) [ "$status" = "healthy" ] && return 0 [ "$status" = "unhealthy" ] && { echo "container reported unhealthy"; return 1; } sleep 1 done echo "health status stuck at: ${status:-}"; return 1 } step "docker reports container healthy" check_docker_health # The point of the probe: an app that runs but cannot reach Postgres must not # pass as healthy. Uses a throwaway container so the real database is untouched. check_health_degraded() { docker rm -f "$SICK_CONTAINER" >/dev/null 2>&1 || true docker run -d --name "$SICK_CONTAINER" -p "127.0.0.1:${SICK_PORT}:3000" \ --health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=2 \ -e DATABASE_URL="postgresql://nobody:nobody@127.0.0.1:5999/nothing" \ -e SESSION_SECRET="ci-only-secret" \ "$IMAGE" >/dev/null local code="" for _ in $(seq 1 30); do code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${SICK_PORT}/api/health" || true) [ -n "$code" ] && [ "$code" != "000" ] && break sleep 1 done [ "$code" = "503" ] || { echo "expected 503 with an unreachable database, got ${code:-no response}"; return 1; } local status for _ in $(seq 1 30); do status=$(docker inspect --format '{{.State.Health.Status}}' "$SICK_CONTAINER" 2>/dev/null) [ "$status" = "unhealthy" ] && { docker rm -f "$SICK_CONTAINER" >/dev/null; return 0; } sleep 1 done echo "container with a dead database never went unhealthy (status: ${status:-})" docker rm -f "$SICK_CONTAINER" >/dev/null 2>&1 || true return 1 } step "health fails when database is down" check_health_degraded # Sign in for real: request a magic link, read it out of the container log, # follow it, and keep the session cookie. sign_in_as() { local email="$1" jar="$2" rm -f "$jar" curl -s -o /dev/null -X POST "http://localhost:${APP_PORT}/api/auth/magic-link" \ -H "Content-Type: application/json" \ -H "Origin: http://localhost:${APP_PORT}" \ -d "{\"email\":\"${email}\",\"callback\":\"/admin\"}" local link="" for _ in $(seq 1 15); do link=$(docker logs "$APP_CONTAINER" 2>&1 \ | grep -oE "http://localhost:${APP_PORT}/api/auth/verify\?[^ ]+" | tail -1) [ -n "$link" ] && break sleep 1 done [ -n "$link" ] || { echo "no magic link was logged for ${email}"; return 1; } curl -s -c "$jar" -o /dev/null "$link" grep -q "szp_session" "$jar" || { echo "no session cookie for ${email}"; return 1; } } # The admin *shell* is public HTML — it carries no data. What must be refused # is the API behind it. check_anonymous_locked_out() { local code code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/api/admin/campaigns") [ "$code" = "401" ] || { echo "/api/admin/campaigns returned $code while signed out"; return 1; } curl -s "http://localhost:${APP_PORT}/api/auth/session" | grep -q '"isAdmin":false' \ || { echo "anonymous session claims admin"; return 1; } curl -s "http://localhost:${APP_PORT}/admin" | grep -q '"route":"admin"' \ || { echo "admin shell did not render"; return 1; } curl -s "http://localhost:${APP_PORT}/admin" | grep -qi 'goalAmount' \ && { echo "admin shell leaked campaign data to an anonymous visitor"; return 1; } return 0 } step "anonymous cannot reach admin data" check_anonymous_locked_out check_admin_signin() { sign_in_as "admin@example.invalid" /tmp/ci-admin.jar || return 1 curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/auth/session" \ | grep -q '"isAdmin":true' || { echo "allowlisted admin is not admin"; return 1; } local code code=$(curl -s -b /tmp/ci-admin.jar -o /dev/null -w '%{http_code}' \ "http://localhost:${APP_PORT}/api/admin/campaigns") [ "$code" = "200" ] || { echo "/api/admin/campaigns returned $code for an admin"; return 1; } } step "magic-link sign-in reaches admin" check_admin_signin # The important one: signing in is not the same as being an admin. check_non_admin_refused() { sign_in_as "nobody@example.invalid" /tmp/ci-other.jar || return 1 curl -s -b /tmp/ci-other.jar "http://localhost:${APP_PORT}/api/auth/session" \ | grep -q '"email":"nobody@example.invalid"' \ || { echo "the non-admin did not actually get a session"; return 1; } curl -s -b /tmp/ci-other.jar "http://localhost:${APP_PORT}/api/auth/session" \ | grep -q '"isAdmin":false' || { echo "a signed-in non-admin was granted admin"; return 1; } local code code=$(curl -s -b /tmp/ci-other.jar -o /dev/null -w '%{http_code}' \ "http://localhost:${APP_PORT}/api/admin/campaigns") [ "$code" = "401" ] || { echo "a signed-in non-admin reached admin data (got $code)"; return 1; } } step "signed-in non-admin is refused" check_non_admin_refused # A magic link is single-use. Following the same one twice must not sign in. check_link_single_use() { local link link=$(docker logs "$APP_CONTAINER" 2>&1 \ | grep -oE "http://localhost:${APP_PORT}/api/auth/verify\?[^ ]+" | tail -1) rm -f /tmp/ci-replay.jar curl -s -c /tmp/ci-replay.jar -o /dev/null "$link" grep -q "szp_session" /tmp/ci-replay.jar \ && { echo "a used magic link signed someone in again"; return 1; } return 0 } step "magic link is single-use" check_link_single_use # Cross-origin writes must be refused even with a valid session cookie. check_csrf_guard() { local code code=$(curl -s -b /tmp/ci-admin.jar -o /dev/null -w '%{http_code}' \ -X PUT "http://localhost:${APP_PORT}/api/admin/campaigns" \ -H "Content-Type: application/json" -H "Origin: https://evil.example" \ -d '{"title":"x"}') [ "$code" = "403" ] || { echo "cross-origin write returned $code, expected 403"; return 1; } } step "cross-origin writes refused" check_csrf_guard check_admin_editor_data() { curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns/${SLUG}" \ | python3 -c ' import json, sys c = json.load(sys.stdin) assert c["tiers"], "no tiers in the editor payload" assert any(t["pledgeCount"] > 0 for t in c["tiers"]), "no tier reports its pledges" assert c["story"], "no story sections" ' } step "admin editor payload complete" check_admin_editor_data # A save whose slug belongs to another campaign must come back 409. The path # that produces it matches the unique index BY NAME from Go # (store/admin.go: isUniqueViolation(err, "campaigns_slug_key")), so renaming # that index without updating the string degrades this silently into a 500 — # no compile error, nothing else here would notice. check_slug_conflict() { local other body code other=$(curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns" \ | python3 -c ' import json, sys mine = sys.argv[1] for r in json.load(sys.stdin): if r["slug"] != mine: print(r["slug"]); break ' "$SLUG") [ -n "$other" ] || { echo "fixture has no second campaign to collide with"; return 1; } # Load, change the slug, save it back — exactly what the editor does. body=$(curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns/${SLUG}" \ | python3 -c ' import json, sys c = json.load(sys.stdin) c["slug"] = sys.argv[1] print(json.dumps(c)) ' "$other") code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT -b /tmp/ci-admin.jar \ -H "Content-Type: application/json" -H "Origin: http://localhost:${APP_PORT}" \ -d "$body" "http://localhost:${APP_PORT}/api/admin/campaigns") [ "$code" = "409" ] || { echo "a colliding slug returned $code, expected 409"; return 1; } } step "a duplicate slug is refused" check_slug_conflict # Campaigns are created by hand in the database. The editor can only change one # that already exists, and the API has to enforce that rather than trust it: # a save with no id used to insert a new campaign. check_creation_refused() { local code code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ -b /tmp/ci-admin.jar \ -H "Content-Type: application/json" \ -H "Origin: http://localhost:${APP_PORT}" \ -d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","ownerId":"x","status":"AKTIV"}' \ "http://localhost:${APP_PORT}/api/admin/campaigns") [ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; } # And it must not have landed anyway. local n n=$(curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/campaigns" \ | grep -c '"slug":"uj-kampany"' || true) [ "$n" = "0" ] || { echo "the refused campaign was created regardless"; return 1; } } step "campaigns cannot be created through the API" check_creation_refused # Every campaign has an owner, and the owner may have a photo. The column is # nullable and the client falls back to the initials chip, so both states have # to survive the trip: set, and cleared again. check_owner_photo() { docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \ -c "UPDATE owners SET avatar_url = 'https://example.invalid/o.jpg'" >/dev/null curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c ' import json, sys campaigns = json.load(sys.stdin) assert campaigns, "no campaigns to check" for c in campaigns: assert c["creator"]["avatarUrl"] == "https://example.invalid/o.jpg", c["creator"] ' || return 1 # A future owner editor loads the photo from here. curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/owners" | python3 -c ' import json, sys owners = json.load(sys.stdin) assert owners, "no owners" for o in owners: assert o.get("avatarUrl"), o ' || return 1 docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \ -c "UPDATE owners SET avatar_url = NULL" >/dev/null curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c ' import json, sys for c in json.load(sys.stdin): assert "avatarUrl" not in c["creator"], "a missing photo must be omitted, not sent as an empty string" ' } step "owner photo round-trips, and is omitted when unset" check_owner_photo check_404() { local code code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen") [ "$code" = "404" ] || { echo "unknown campaign returned $code, expected 404"; return 1; } code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/nincs-ilyen-oldal") [ "$code" = "404" ] || { echo "unknown page returned $code, expected 404"; return 1; } } step "unknown routes are 404" check_404 # The admin's way in is the landing page with a sign-in link. Nothing links to # it, so the only thing holding it up is the server case that answers it: every # unrecognised path renders a 404 document, and it would fall into that. check_admin_entry() { local out out=$(curl -s -o /dev/null -D - -w '%{http_code}' "http://localhost:${APP_PORT}/admin-belepes") case "$out" in *200) ;; *) echo "the admin entry page returned something other than 200"; return 1 ;; esac printf '%s' "$out" | grep -qi 'X-Robots-Tag: *noindex' \ || { echo "the admin entry page is missing its noindex header"; return 1; } # And it must not be advertised on the public landing page. curl -s -D - -o /dev/null "http://localhost:${APP_PORT}/" | grep -qi 'X-Robots-Tag' \ && { echo "the landing page should not be noindex"; return 1; } return 0 } step "admin entry page is served and unindexed" check_admin_entry # A draft campaign must be as invisible as one that does not exist. check_draft_hidden() { local code code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/api/campaigns/nincs-ilyen") [ "$code" = "404" ] || { echo "unknown campaign API returned $code"; return 1; } } step "non-public campaigns are hidden" check_draft_hidden END=$(date +%s) echo if [ ${#FAILED[@]} -eq 0 ]; then bold "all checks passed in $((END - START))s" exit 0 else bold "FAILED (${#FAILED[@]}): ${FAILED[*]}" exit 1 fi