From 1b98abaaf218c68a4ac45c6064349110874ddda0 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:53:07 +0200 Subject: [PATCH] feat(ci): audit the fleet for the Supabase we retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet self-hosts Supabase on bitbaum. Two managed-cloud projects are dead — orangecat's ohkueislstxomdjavyhs, retired 2026-06, and botsmann's jkjmhtirxwhljpkcfxqe before it. The repos kept describing them in the present tense. botsmann's setup doc opened with "Completed Setup" for its dead project and said to apply migrations by pasting SQL into a supabase.com dashboard. Two scripts printed the same instruction, one handing out a psql string against pooler.supabase.com, and the CLI's cache with the old project ref was committed twice. Nobody pasted anything, its eleven migrations were never applied, and /api/health served PGRST205 for months while every deploy went green. The docs were not stale clutter; they were the outage. A wrong pointer to a real-looking place is worse than no pointer: no pointer makes someone ask, a plausible one makes them assume it was handled. The first live sweep found one more of the same shape, which is the argument for auditing rather than trusting the cleanup: printcraft's seed script hardcoded the retired host AND omitted `db: { schema }`, so repointing it naively at the box would have written one customer's rows into orangecat's `public`. Deleted in bitbaum/printcraft#30. Central, not a copy per repo — the rule this repo exists to keep. Three repos use Supabase and thirty do not. botsmann additionally keeps a local check:selfhost in its own verify, deliberately: it is the repo the outage happened in, and blocking the commit beats finding it a week later. Two judgements decide whether this is useful or merely loud, and both are pinned by 28 tests. It allows supabase.com/docs, because the product documentation is still correct for us, and your-project.supabase.co, because a placeholder misleads nobody — a gate that fires on either gets muted. And legitimate mentions, like a decommission runbook naming what it decommissioned, live in the baseline WITH A REASON rather than widening the pattern: a ratchet that may fall or hold, never rise. Unlike verify-floor-audit.sh this reads local checkouts, because it is a full-text sweep and GitHub code search returns nothing for these repos — even a known-present token finds no hit. So the workflow shallow-clones the fleet and runs the same script unchanged. When it sweeps nothing it says SKIPPED, loudly, rather than printing a tick: a vacuous pass reads exactly like coverage, which is the failure this audit exists to prevent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb --- .github/workflows/ci.yml | 3 + .github/workflows/hosted-supabase.yml | 80 +++++++++++ SHARED.md | 1 + scripts/ci/hosted-supabase-audit.sh | 174 +++++++++++++++++++++++ scripts/ci/hosted-supabase.baseline | 13 ++ scripts/ci/test-hosted-supabase-audit.sh | 92 ++++++++++++ 6 files changed, 363 insertions(+) create mode 100644 .github/workflows/hosted-supabase.yml create mode 100755 scripts/ci/hosted-supabase-audit.sh create mode 100644 scripts/ci/hosted-supabase.baseline create mode 100755 scripts/ci/test-hosted-supabase-audit.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ae5380..3bee84a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,9 @@ jobs: - name: The model-pin audit still detects, and still stays quiet run: node scripts/ci/test-model-pin-audit.mjs + - name: The hosted-Supabase audit still detects, and still stays quiet + run: bash scripts/ci/test-hosted-supabase-audit.sh + # Drift guard. The fleet is on v7; templates handing out v4 is exactly # how this repo fell behind the repos it governs. - name: No stale action versions diff --git a/.github/workflows/hosted-supabase.yml b/.github/workflows/hosted-supabase.yml new file mode 100644 index 0000000..2ef192d --- /dev/null +++ b/.github/workflows/hosted-supabase.yml @@ -0,0 +1,80 @@ +# The fleet self-hosts Supabase. This proves no repo still says otherwise. +# +# botsmann served PGRST205 from /api/health for months because its setup doc +# described a managed-cloud project we had already left, and told you to apply +# migrations by pasting SQL into a dashboard that does not exist for us. Nobody +# pasted anything, so its eleven migrations were never applied — while every +# deploy went green. The docs were not stale clutter; they were the outage. +# +# Fixing those files once clears today's copies. This is what stops the next one. +name: Hosted Supabase audit + +on: + schedule: + # Weekly. A hosted reference enters on the timescale of someone writing a + # doc or copying an old script, not of individual commits — daily would be + # noise, and noise gets muted. + - cron: '27 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hosted-supabase-audit + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + # Pure, no checkout of anything else. If the audit's own judgement breaks, + # the sweep below would still print a confident tick. + - name: The audit still detects, and still stays quiet + run: bash scripts/ci/test-hosted-supabase-audit.sh + + # GitHub code search returns nothing for these repos, so a full-text sweep + # cannot be done through the API. Shallow-cloning is the cheap honest + # alternative: --depth 1 --filter=blob:none keeps it to seconds per repo, + # and the audit reads `origin/main`, never a working tree. + - name: Clone the fleet + env: + # Same gap as the shared inventory, stated rather than hidden: the + # default token reads public repos only, so private ones are silently + # omitted. The audit prints how many repos it swept — a drop in that + # number is the tell. Set FLEET_READ_TOKEN to cover all of them. + GH_TOKEN: ${{ secrets.FLEET_READ_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -uo pipefail + mkdir -p "$RUNNER_TEMP/dev" + names=$(gh repo list bitbaum --limit 200 --no-archived --json name --jq '.[].name') + [ -n "$names" ] || { echo "::error::no repos listed — the token cannot see the fleet"; exit 1; } + n=0 + for name in $names; do + if gh repo clone "bitbaum/$name" "$RUNNER_TEMP/dev/$name" -- \ + --depth 1 --filter=blob:none --quiet 2>/dev/null; then + n=$((n + 1)) + else + echo "::warning::could not clone $name — it will not be audited" + fi + done + echo "cloned $n repo(s)" + echo "DEV_ROOT=$RUNNER_TEMP/dev" >> "$GITHUB_ENV" + + - name: No repo points at a hosted Supabase + run: | + set -uo pipefail + { + echo '## Hosted Supabase audit' + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + set +e + bash scripts/ci/hosted-supabase-audit.sh --check 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" + rc=${PIPESTATUS[0]} + set -e + echo '```' >> "$GITHUB_STEP_SUMMARY" + exit "$rc" diff --git a/SHARED.md b/SHARED.md index e59a629..c1fd58e 100644 --- a/SHARED.md +++ b/SHARED.md @@ -120,6 +120,7 @@ fleet-wide checker. | `scripts/ci/verify-floor-audit.sh` | does every repo's `verify` actually run lint + typecheck + test? | | `scripts/ci/model-pin-audit.mjs` | is any model id the fleet pins no longer served by its vendor? Zero tokens — one `GET /models` per vendor — so it runs DAILY. Uses `ai-kit`'s `checkCatalog` rather than a second vendor query. Self-tested by `scripts/ci/test-model-pin-audit.mjs` — **102 checks, no network, no key, no checkout**, every fixture the real code that fooled it. Using it to repair seven repos on 2026-08-27 exposed nine faults in both directions, and the blind spots were not random: they mirrored the shapes people write (`GROQ_MODELS = {` defeats `\bmodels?\b`; `models: AIModel[] = [` defeats an array pattern; `modelId` is not `model`). It also read ids out of COMMENTS — reporting a retired id in the very commit that removed it. Never trust its first clean run after widening; re-run the live sweep and read every line. | | `scripts/ci/ui-defect-audit.mjs` | do any live sites ship an interactive label below its WCAG AA floor, or a stack whose rows start at different x? Renders each site; no repo checkout involved. Self-tested by `scripts/ci/test-ui-defect-audit.mjs`, which pins BOTH sides — the real defect is still caught, correct markup stays silent. | +| `scripts/ci/hosted-supabase-audit.sh` | does any repo still point at a Supabase we retired? The fleet self-hosts on bitbaum; two managed-cloud projects are dead. botsmann kept a setup doc opening with "Completed Setup" for one of them, telling you to apply migrations by pasting SQL into a dashboard that does not exist for us — so nobody pasted anything, its eleven migrations were never applied, and `/api/health` served PGRST205 for months while every deploy went green. The docs were not stale clutter; they were the outage. Allows `supabase.com/docs` (the product documentation is still correct) and `your-project.supabase.co` (a placeholder misleads nobody) — a gate that fires on those gets muted. Legitimate mentions, like a decommission runbook naming what it decommissioned, live in `hosted-supabase.baseline` **with a reason**: a ratchet that may fall or hold, never rise. GitHub code search returns nothing for these repos, so the workflow shallow-clones the fleet and runs the same script — and says SKIPPED, loudly, when it sweeps nothing, because a vacuous pass reads exactly like coverage. Self-tested by `scripts/ci/test-hosted-supabase-audit.sh` — **28 checks, no network, no checkout**, pinning both sides. | Both report into a weekly workflow's job summary rather than only a log. diff --git a/scripts/ci/hosted-supabase-audit.sh b/scripts/ci/hosted-supabase-audit.sh new file mode 100755 index 0000000..0303c53 --- /dev/null +++ b/scripts/ci/hosted-supabase-audit.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# Fleet audit: does any repo still point at a HOSTED Supabase? +# +# hosted-supabase-audit.sh [--check] [--list] +# +# WHY +# +# The fleet self-hosts Supabase on bitbaum. Two managed-cloud projects were +# retired — orangecat's `ohkueislstxomdjavyhs` in 2026-06 and botsmann's +# `jkjmhtirxwhljpkcfxqe` before it — but the repos kept describing them in the +# present tense. botsmann's setup doc opened with "Completed Setup" for the dead +# project and told you to apply migrations by pasting SQL into a dashboard we do +# not have. Nobody pasted anything, its eleven migrations were never applied, +# and /api/health served PGRST205 for months while every deploy went green. +# +# So this is not tidying. A wrong pointer to a real-looking place is worse than +# no pointer: no pointer makes someone ask, a plausible one makes them assume it +# was handled. The first live sweep, 2026-08-28, found one more of exactly that +# shape — printcraft/scripts/seed-roli-project.ts hardcoded the retired host AND +# omitted `db: { schema }`, so repointing it naively at the box would have +# written one customer's rows into orangecat's `public`. +# +# CENTRAL, NOT A COPY PER REPO — the rule this repo already lives by. Three +# repos use Supabase and thirty do not; a gate copied thirty times is the +# duplication SHARED.md measures. botsmann additionally keeps a local +# `check:selfhost` in its own verify, deliberately: it is the repo the outage +# happened in, and blocking the commit beats finding it a week later. +# +# WHY LOCAL CHECKOUTS RATHER THAN `gh api`, unlike verify-floor-audit.sh — +# that one reads ONE small file per repo, which is cheap remotely. This is a +# full-text sweep, and GitHub code search returns nothing for these repos +# (verified 2026-08-28: even a known-present token finds no hit), so the only +# remote option would be fetching every text file of every repo. Instead the +# workflow shallow-clones the fleet into a temp DEV_ROOT and runs this +# unchanged — the script never needs to know which it is looking at. +# +# THE BASELINE IS A RATCHET +# +# Some references are legitimate: a decommission runbook has to name the host it +# decommissioned, a migration history has to name what it moved off. Those live +# in hosted-supabase.baseline with a reason, decided once by a human. The list +# may FALL or hold; it may never RISE without that decision being visible in the +# same PR that adds the line. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" +BASELINE="${BASELINE:-$HERE/hosted-supabase.baseline}" + +# ---------------------------------------------------------------- pure helpers + +# hosted_pattern — what counts as pointing at a hosted Supabase. +# +# `supabase.com/docs` is deliberately absent: the product documentation is still +# correct for us, only the control plane is not. A project ref is a long opaque +# token, so `your-project.supabase.co` in an example file stays legal — a +# placeholder misleads nobody, and a gate that fires on one gets muted. +hosted_pattern() { + printf '%s' 'supabase\.com/dashboard|app\.supabase\.com|pooler\.supabase\.com|[a-z0-9]{15,}\.supabase\.co' +} + +# is_exempt_path — paths whose whole job is to describe the old world. +is_exempt_path() { + case "$1" in + docs/archive/*|*/docs/archive/*) return 0 ;; + *hosted-supabase-audit.sh|*hosted-supabase.baseline) return 0 ;; + *test-hosted-supabase-audit.sh|*no-hosted-supabase.sh) return 0 ;; + *) return 1 ;; + esac +} + +# baseline_keys — `repo/path` per line, comments and blanks dropped. +baseline_keys() { + [ -f "$1" ] || return 0 + sed -e 's/#.*//' -e 's/[[:space:]]*$//' -e '/^[[:space:]]*$/d' "$1" +} + +# in_baseline — exact match, so a prefix cannot smuggle a file in. +in_baseline() { + local key="$1"; shift + local k + for k in "$@"; do [ "$k" = "$key" ] && return 0; done + return 1 +} + +# repo_ref — audit what is SHARED, not what a session happens to have +# checked out. A stale local main reports violations already fixed upstream: +# the first run of this audit did exactly that and blamed four clean repos. +repo_ref() { + git -C "$1" rev-parse --verify -q origin/main >/dev/null 2>&1 && { printf 'origin/main'; return; } + git -C "$1" rev-parse --verify -q origin/master >/dev/null 2>&1 && { printf 'origin/master'; return; } + printf 'HEAD' +} + +if [ -n "${HOSTED_SUPABASE_AUDIT_LIB_ONLY:-}" ]; then return 0; fi + +# ---------------------------------------------------------------------- sweep + +MODE=check +case "${1:-}" in + --check|"") MODE=check ;; + --list) MODE=list ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; +esac + +mapfile -t KEYS < <(baseline_keys "$BASELINE") +PATTERN="$(hosted_pattern)" + +new_hits=(); seen_keys=(); scanned_repos=() + +for gitdir in "$DEV_ROOT"/*/.git; do + # A linked worktree's .git is a FILE, not a directory. Skipping them stops one + # repo being audited twice under two names — the first run reported fleetcrown + # and fleetcrown-scripts as separate offenders for a single line. + [ -d "$gitdir" ] || continue + repo_dir="${gitdir%/.git}" + repo="$(basename "$repo_dir")" + ref="$(repo_ref "$repo_dir")" + scanned_repos+=("$repo") + + while IFS= read -r line; do + [ -n "$line" ] || continue + path="${line%%:*}" + rest="${line#*:}" + is_exempt_path "$path" && continue + key="$repo/$path" + seen_keys+=("$key") + in_baseline "$key" "${KEYS[@]:-}" || new_hits+=("$key:$rest") + done < <(git -C "$repo_dir" grep -nEI "$PATTERN" "$ref" 2>/dev/null | sed "s|^$ref:||" || true) +done + +# A runner with only this repo checked out would sweep nothing and pass. A +# vacuous pass reads exactly like coverage — the failure this audit exists to +# prevent — so say so out loud instead of printing a tick. +if [ ${#scanned_repos[@]} -eq 0 ]; then + echo "⊘ hosted-Supabase audit SKIPPED — no fleet checkout under $DEV_ROOT." + echo " This is not a pass. Run it where the repos live." + exit 0 +fi + +if [ "$MODE" = list ]; then + printf '%s\n' "${seen_keys[@]:-}" | sort -u + exit 0 +fi + +# A baseline entry whose reference is gone is a licence nobody needs. Only prune +# entries for repos actually scanned — an absent checkout is not proof. +stale=() +for k in "${KEYS[@]:-}"; do + [ -n "$k" ] || continue + [ -d "$DEV_ROOT/${k%%/*}/.git" ] || continue + in_baseline "$k" "${seen_keys[@]:-}" || stale+=("$k") +done + +if [ ${#new_hits[@]} -gt 0 ]; then + echo "✗ hosted-Supabase reference(s) not in the baseline:" >&2 + printf ' %s\n' "${new_hits[@]}" >&2 + echo >&2 + echo " The fleet self-hosts at supabase.orangecat.ch. If this is a live" >&2 + echo " instruction, fix it. If it is a historical record, add the 'repo/path'" >&2 + echo " line to $(basename "$BASELINE") WITH A REASON, in the same PR, so the" >&2 + echo " exception is a decision and not an inheritance." >&2 + exit 1 +fi + +if [ ${#stale[@]} -gt 0 ]; then + echo "✗ baseline entries with no matching reference — the ratchet must fall:" >&2 + printf ' %s\n' "${stale[@]}" >&2 + echo " Delete these lines from $(basename "$BASELINE")." >&2 + exit 1 +fi + +echo "✓ ${#scanned_repos[@]} repos swept, no hosted-Supabase reference outside the baseline (${#seen_keys[@]} allowed)" diff --git a/scripts/ci/hosted-supabase.baseline b/scripts/ci/hosted-supabase.baseline new file mode 100644 index 0000000..ff2c9ca --- /dev/null +++ b/scripts/ci/hosted-supabase.baseline @@ -0,0 +1,13 @@ +# References to the RETIRED managed-cloud Supabase projects that are allowed to +# stay, because their job is to record that we left. Everything else fails +# hosted-supabase-audit.sh. +# +# Format: / # why this one is legitimate +# +# This list may FALL or hold. It may never RISE without a human deciding so in +# the same PR that adds the line. + +fleetcrown/docs/infrastructure/hetzner-migration.md # the migration's own record of what we moved off +orangecat/.claude/CLAUDE.md # warns the cloud project is retired — that is the point +orangecat/docs/operations/DECOMMISSION-CLOUD.md # the decommission runbook must name what it decommissioned +orangecat/next.config.js # comment marking the retired host, beside the rule that replaced it diff --git a/scripts/ci/test-hosted-supabase-audit.sh b/scripts/ci/test-hosted-supabase-audit.sh new file mode 100755 index 0000000..36ebe16 --- /dev/null +++ b/scripts/ci/test-hosted-supabase-audit.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# Tests for the hosted-Supabase audit — chiefly the two judgements that decide +# whether it is useful or merely loud. +# +# 1. It must catch a REAL pointer: a dashboard link, a pooler host, a project +# ref. botsmann's setup doc carried all three shapes, and the app served +# PGRST205 for months because nobody could tell they were dead. +# 2. It must NOT catch a placeholder or the product docs. A gate that fires on +# `your-project.supabase.co`, or on a link to supabase.com/docs, gets muted +# — and a muted gate protects nothing. +# +# Pure: no network, no box, no fleet checkout. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/hosted-supabase-audit.sh" + +PASS=0; FAIL=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +no() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } +eq() { [ "$1" = "$2" ] && ok "$3" || no "$3 (want '$1', got '$2')"; } +matches() { printf '%s' "$1" | grep -qE "$(hosted_pattern)" && ok "$2" || no "$2 (should match)"; } +no_match() { printf '%s' "$1" | grep -qE "$(hosted_pattern)" && no "$2 (should NOT match)" || ok "$2"; } + +export HOSTED_SUPABASE_AUDIT_LIB_ONLY=1 +# shellcheck source=/dev/null +source "$SCRIPT" +unset HOSTED_SUPABASE_AUDIT_LIB_ONLY + +echo "hosted_pattern — the shapes that actually misled someone" +matches 'https://supabase.com/dashboard/project/_/sql' "a dashboard link, botsmann's documented migration step" +matches 'https://supabase.com/dashboard/account/tokens' "the account-tokens link orangecat's runbook carried" +matches 'https://app.supabase.com' "the older app. host" +matches 'psql -h aws-0-eu-central-1.pooler.supabase.com -p 6543' "a pooler connection string" +matches 'https://jkjmhtirxwhljpkcfxqe.supabase.co' "botsmann's retired project ref" +matches 'https://ckpynkpsfnuqndplaapc.supabase.co' "printcraft's retired project ref" +matches 'db.ohkueislstxomdjavyhs.supabase.co:5432' "orangecat's, inside a pg_dump URL" + +echo +echo "hosted_pattern — what must stay legal, or the gate gets muted" +no_match 'https://supabase.com/docs/guides/storage' "product docs are still correct for us" +no_match 'https://supabase.com/docs' "a bare docs link" +no_match 'NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"' "a placeholder misleads nobody" +no_match 'https://supabase.orangecat.ch' "our own self-hosted host" +no_match 'import { createClient } from "@supabase/supabase-js"' "the package name is not a host" + +echo +echo "is_exempt_path — paths whose job is to describe the old world" +is_exempt_path 'docs/archive/2026-h1/NOTES.md' && ok "docs/archive is history, not instruction" || no "docs/archive should be exempt" +is_exempt_path 'app/docs/archive/OLD.md' && ok "a nested docs/archive too" || no "nested archive should be exempt" +is_exempt_path 'scripts/ci/hosted-supabase.baseline' && ok "the baseline names the refs it allows" || no "baseline should be exempt" +is_exempt_path 'scripts/ci/no-hosted-supabase.sh' && ok "botsmann's local gate states the patterns" || no "local gate should be exempt" +is_exempt_path 'docs/operations/DECOMMISSION.md' && no "a live runbook must NOT be auto-exempt" || ok "a runbook is judged, not waved through" +is_exempt_path '.env.example' && no ".env.example must NOT be auto-exempt" || ok ".env.example is judged" + +echo +echo "baseline_keys — comments and blanks are not licences" +TMP="$(mktemp)" +printf '# a header\n\nfoo/bar.md # why it is allowed\n\n \nbaz/qux.ts\n' > "$TMP" +eq 'foo/bar.md +baz/qux.ts' "$(baseline_keys "$TMP")" "reasons stripped, blanks dropped, keys kept" +eq '' "$(baseline_keys /nonexistent/baseline)" "a missing baseline allows nothing, and does not crash" + +echo +echo "in_baseline — exact keys, so a prefix cannot smuggle a file through" +in_baseline 'a/b.md' 'a/b.md' 'c/d.md' && ok "an exact key is allowed" || no "exact key should match" +in_baseline 'a/b.md' 'a/b.md.bak' && no "a longer path must not match" || ok "no substring match" +in_baseline 'a/b.md' 'x/y.md' && no "an unrelated key must not match" || ok "unrelated key rejected" +in_baseline 'a/b.md' && no "an empty baseline allows nothing" || ok "empty baseline allows nothing" + +echo +echo "repo_ref — audit what is SHARED, not a session's stale checkout" +D="$(mktemp -d)" +trap 'rm -rf "$D" "$TMP"' EXIT +git -C "$D" init -q 2>/dev/null +git -C "$D" commit -q --allow-empty -m init 2>/dev/null +eq HEAD "$(repo_ref "$D")" "no remote falls back to HEAD rather than failing the sweep" +git -C "$D" update-ref refs/remotes/origin/main HEAD +eq origin/main "$(repo_ref "$D")" "origin/main wins — a local main 4 commits stale reports fixed files as broken" + +echo +echo "the sweep must never pass vacuously" +out="$(DEV_ROOT=/nonexistent bash "$SCRIPT" --check 2>&1)"; rc=$? +eq 0 "$rc" "no checkout exits 0, so a runner without the fleet is not a red herring" +printf '%s' "$out" | grep -q 'SKIPPED' && ok "but it says SKIPPED — a vacuous pass would read as coverage" \ + || no "must announce the skip, not print a tick" + +echo +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ]