Ctrl K
ComunidadGuías…

preflight.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# preflight.sh — run everything CI runs, locally, before pushing.
#
# The whole point: failures get found HERE, not in a GitHub CI round-trip.
# Mirrors .github/workflows/ci.yml and security.yml as of 2026-08-15, with
# two deliberate strictness additions (go vet; svelte-check) that CI lacks.
#
#   ./preflight.sh                 full run (~13-19 min; long pole is
#                                   `go test -race ./...` — CI's own
#                                   comment on that step measured 581s on
#                                   a loaded machine, #329)
#   PREFLIGHT_FAST=1 ./preflight.sh    skip `npm ci` when node_modules exists
#   PREFLIGHT_COVERAGE=1 ./preflight.sh  also run the CI coverage pass (re-runs suite)
#   PREFLIGHT_SCAN_PROCS=N ./preflight.sh  CPU cap for gosec/govulncheck/
#                                   go-licenses while they run in the
#                                   background alongside the test suite
#                                   (default 2 — see the note above the
#                                   gosec docker run below for why this
#                                   exists)
#
# Requires: go (toolchain per go.mod), node (20 preferred — warns on drift),
# docker (for CI-identical gosec; skipped loudly if absent), jq.
#
# CPU note: unlike CI, which runs lint/test/build/security as five
# separate jobs on five separate VMs (zero contention by construction),
# every stage here shares one machine's cores. `go test -race ./...` is
# deliberately left at its default (uncapped) parallelism, matching
# ci.yml's own invocation exactly — it is the long pole and the
# correctness-critical step. The background gosec/govulncheck/go-licenses
# scans are capped via PREFLIGHT_SCAN_PROCS so they can make progress
# concurrently with the test suite (preserving the wall-clock win of not
# running them sequentially) without fighting it for every core.
set -euo pipefail
cd "$(dirname "$0")"

LOGDIR=.preflight
mkdir -p "$LOGDIR"
FAILED=0
START=$(date +%s)
PREFLIGHT_SCAN_PROCS="${PREFLIGHT_SCAN_PROCS:-2}"

say()  { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
pass() { printf '\033[32mPASS\033[0m %s (%ss)\n' "$1" "$(( $(date +%s) - STAGE_START ))"; }
fail() { printf '\033[31mFAIL\033[0m %s — see %s\n' "$1" "$2"; FAILED=1; }

# shellcheck source=scripts/preflight-govulncheck-verdict.sh
source scripts/preflight-govulncheck-verdict.sh

stage() { STAGE_START=$(date +%s); say "$1"; }

# ---- 1. gofmt — CI parity: UNFILTERED repo root (stricter than lint.sh) ----
stage "gofmt (unfiltered, CI parity)"
UNFORMATTED=$(gofmt -l .)
if [ -n "$UNFORMATTED" ]; then
  echo "$UNFORMATTED"
  echo "run: gofmt -w ."
  exit 1
fi
pass gofmt

# ---- 2. outbound HTTP policy (CI parity: ci.yml's lint job) ----
#
# This was missing while CI ran it, so a violation cost a full CI
# round-trip to discover -- exactly what this script exists to prevent.
# It is cheap and it guards ADR-022, so it runs before the long stages
# rather than alongside them.
stage "outbound HTTP policy (netguardcheck)"
go run ./internal/cmd/netguardcheck ./... 2>&1 | tee "$LOGDIR/netguard.log"
pass netguard

# ---- 3. go vet (not in CI's lint job — deliberate extra rigor) ----
stage "go vet"
go vet ./... 2>&1 | tee "$LOGDIR/vet.log"
pass vet

# ---- background security stages (don't need the UI build) ----
say "launching parallel security scans (gosec / govulncheck / licenses)"
(
  if command -v docker >/dev/null 2>&1; then
    # gosec's own image/digest is pinned deliberately (see below) and NOT
    # what makes this slow. `--rm` gives every invocation a brand-new
    # container with an empty $GOPATH/pkg/mod and empty build cache, so
    # without the two named volumes below, gosec re-downloads and
    # re-compiles this module's ENTIRE dependency graph from scratch on
    # every single preflight run, fighting go test -race for CPU the
    # whole time it does. Named volumes persist across --rm (only the
    # container is ephemeral, not the volume), so the first run after
    # this change is still cold; every run after that reuses them. This
    # is a separate, container-local cache -- never the host's own
    # GOMODCACHE/GOCACHE -- specifically to avoid a root-in-container
    # write leaving host-owned cache files an unprivileged host `go`
    # invocation can't later read or overwrite.
    docker run --rm -v "$PWD":/src -w /src \
      -v brokoli-preflight-gosec-gomodcache:/go/pkg/mod \
      -v brokoli-preflight-gosec-gocache:/gocache \
      -e GOCACHE=/gocache \
      -e GOMAXPROCS="$PREFLIGHT_SCAN_PROCS" --cpus="$PREFLIGHT_SCAN_PROCS" \
      ghcr.io/securego/gosec@sha256:4342ad119a7c69f3f4e4ce78d81ba183dc774a70a7a4c6eeb15fe9e511f214f0 \
      -no-fail -fmt=json -out=/src/$LOGDIR/gosec.json -exclude-dir=.preflight ./... >/dev/null 2>&1
    # Assert the scan happened before trusting its verdict: a baseline
    # comparison treats "0 findings" as the best possible outcome, so a
    # gosec run that inspected nothing passes while suggesting the
    # baseline be lowered to 0. Mirrors ci parity with security.yml, and
    # the reason both now check is scripts/check-licenses.sh.
    files=$(jq -r '.Stats.files // 0' "$LOGDIR/gosec.json" 2>/dev/null || echo 0)
    errs=$(jq -r '(."Golang errors" // {}) | length' "$LOGDIR/gosec.json" 2>/dev/null || echo 0)
    if [ "$files" -lt 1 ]; then
      echo "gosec scanned $files files — the scan inspected nothing" > "$LOGDIR/gosec.verdict"; exit 1
    fi
    if [ "$errs" -gt 0 ]; then
      echo "gosec hit $errs Go build error(s) — partial scan, verdict not comparable to the baseline" > "$LOGDIR/gosec.verdict"; exit 1
    fi
    findings=$(jq '[.Issues[]] | length' "$LOGDIR/gosec.json")
    baseline=$(jq -r '.issues' security/gosec-baseline.json)
    echo "gosec findings: $findings (baseline: $baseline) across $files files" > "$LOGDIR/gosec.verdict"
    if [ "$findings" -gt "$baseline" ]; then exit 1; fi
    if [ "$findings" -lt "$baseline" ]; then echo "note: baseline can be reduced to $findings" >> "$LOGDIR/gosec.verdict"; fi
  else
    echo "SKIP: docker not available — CI WILL run gosec; findings there may differ" > "$LOGDIR/gosec.verdict"
  fi
) > "$LOGDIR/gosec.log" 2>&1 &
GOSEC_PID=$!

# Pin GOTOOLCHAIN to the module's toolchain: `go run pkg@version` is
# module-less, so it only upgrades to x/vuln's own minimum (1.25), which
# then can't analyze packages that use newer language features. CI never
# hits this because setup-go installs the go.mod version outright.
MOD_TOOLCHAIN=$(awk '/^toolchain /{print $2}' go.mod)
# Pinned exact versions, not @latest: a tool version bump on any given day
# would otherwise force a fresh, uncached compile (defeating the point of
# a warm build cache) and make results non-deterministic run to run.
# `go install` once into the host's own module/build cache (already 2.9G/
# 2.7G warm on a machine that's built this module before) rather than
# `go run` on every invocation -- `go run` would keep re-resolving the
# module each time. Bump these deliberately, the same way the gosec
# digest above is a deliberate, reviewed bump.
GOVULNCHECK_VERSION=v1.7.0
# The runner prefers a matching installed binary (including a customized
# GOBIN) and only installs when the pinned version is absent.
(
  export GOVULNCHECK_VERSION MOD_TOOLCHAIN
  GOMAXPROCS="$PREFLIGHT_SCAN_PROCS" bash scripts/run-preflight-govulncheck.sh
) > "$LOGDIR/govulncheck.log" 2>&1 &
VULN_PID=$!

(
  # scripts/check-licenses.sh, not go-licenses: go-licenses cannot load a
  # Go 1.24+ stdlib (every package trips "does not have module info"),
  # exits fatally having printed nothing, and the old `|| true` + grep
  # over that empty output passed unconditionally. See the script header.
  GOMAXPROCS="$PREFLIGHT_SCAN_PROCS" bash scripts/check-licenses.sh > "$LOGDIR/licenses.txt"
  bash scripts/check-licenses_test.sh
) > "$LOGDIR/licenses.log" 2>&1 &
LIC_PID=$!

# ---- 3. node version guard (CI pins 20) ----
stage "node version"
NODE_MAJOR=$(node -v | sed 's/^v//' | cut -d. -f1)
if [ "$NODE_MAJOR" != "20" ]; then
  if [ -s "$HOME/.nvm/nvm.sh" ]; then
    # Sourcing nvm.sh mutates PATH to nvm's default — if we then fail to
    # land on 20, RESTORE the original PATH: a half-switched node broke
    # npm's native optional deps the first time this script ran.
    PREFLIGHT_OLD_PATH=$PATH
    # shellcheck disable=SC1091
    . "$HOME/.nvm/nvm.sh"
    if nvm use 20 >/dev/null 2>&1 || { nvm install 20 >/dev/null 2>&1 && nvm use 20 >/dev/null 2>&1; }; then
      echo "using node $(node -v) via nvm (CI parity)"
    else
      PATH=$PREFLIGHT_OLD_PATH
      echo "WARN: node $NODE_MAJOR (CI pins 20); nvm couldn't provide 20 — build may differ from CI"
    fi
  else
    echo "WARN: node v$NODE_MAJOR, CI pins v20 — build may differ from CI"
  fi
else
  echo "node $(node -v) matches CI"
fi
pass node-guard

# ---- 3b. go toolchain parity with the workflows ----
# The workflows pin setup-go to an exact version so Go does not download
# a SECOND toolchain per job on top of the one setup-go installed (which
# it does whenever that version differs from go.mod's toolchain
# directive). Same class of drift the node guard above catches, so the
# same treatment: notice it here rather than in a CI bill.
stage "go toolchain parity (advisory)"
mod_toolchain=$(awk '/^toolchain /{print substr($2,3)}' go.mod)
wf_toolchain=$(grep -ho 'go-version: "[^"]*"' .github/workflows/ci.yml | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ -n "$mod_toolchain" ] && [ "$mod_toolchain" != "$wf_toolchain" ]; then
  printf '\033[33mWARN\033[0m go.mod toolchain (%s) != ci.yml go-version (%s) -- CI will download a second toolchain per job\n' \
    "$mod_toolchain" "$wf_toolchain"
else
  echo "go toolchain $mod_toolchain matches the workflows"
fi
pass go-toolchain-guard

# ---- 4. UI build (go:embed prerequisite) ----
stage "UI build"
pushd ui >/dev/null
if [ "${PREFLIGHT_FAST:-}" = "1" ] && [ -d node_modules ]; then
  echo "PREFLIGHT_FAST: skipping npm ci"
else
  npm ci --silent
fi
popd >/dev/null

# The UI check chain + prettier need only node_modules + source, not the
# Vite build output. They have no ordering dependency on anything else in
# this script, so they run in the background (started right after `npm
# ci`, alongside the Vite build and the security scans) instead of
# blocking serially before the long test run, and are collected in the
# final wait block the same way gosec/govulncheck/go-licenses are.
#
# This block used to run svelte-check and compare its error count against
# ui/svelte-check-baseline.json. The UI is React (brokoli#645) -- there is
# no svelte-check, no ui/src, and no baseline file. Two consequences, both
# live until now:
#
#   `npm run check ... || true` threw the real result away, so a UI
#   typecheck or unit-test failure could not fail preflight. The only
#   thing gating was an error count scraped from output that no longer
#   contains it.
#
#   `jq -r '.errors' svelte-check-baseline.json` read a file that does not
#   exist, which failed the stage on every run no matter what the UI did.
#
# So the stage was simultaneously vacuous and always-red. It now gates on
# the exit status of ui/package.json's own "check" script, which is the
# thing that actually means the UI is sound (typecheck, unit tests,
# colour tokens, build, import boundaries).
(
  cd ui
  if ! npm run check > "../$LOGDIR/ui-check.log" 2>&1; then
    echo "npm run check failed -- see ui-check.log" > "../$LOGDIR/ui-checks.verdict"
    exit 1
  fi
  echo "npm run check: clean (typecheck, tests, colors, build, boundaries)" > "../$LOGDIR/ui-checks.verdict"
  # Prettier gate scoped to CHANGED files only: the tree has never been
  # fully prettier-formatted and CI has no format gate — wholesale
  # reformatting belongs in its own dedicated PR, not as preflight fallout.
  # Sources live under ui/apps/*/src and ui/packages/*/src.
  CHANGED_UI=$( (git -C .. diff --name-only origin/main...HEAD -- ui; git -C .. diff --name-only -- ui; git -C .. ls-files --others --exclude-standard -- ui) | sort -u | grep -E '^ui/(apps|packages)/[^/]+/src/.*\.(ts|tsx|css)$' | sed 's|^ui/||' || true)
  if [ -n "$CHANGED_UI" ]; then
    # shellcheck disable=SC2086
    npx prettier --check $CHANGED_UI > "../$LOGDIR/ui-format.log" 2>&1 || exit 1
    echo "prettier: $(echo "$CHANGED_UI" | wc -l) changed file(s) clean" >> "../$LOGDIR/ui-checks.verdict"
  else
    echo "prettier: no changed UI files" >> "../$LOGDIR/ui-checks.verdict"
  fi
) > "$LOGDIR/ui-checks.log" 2>&1 &
UI_CHECK_PID=$!

pushd ui >/dev/null
npm run build > "../$LOGDIR/ui-build.log" 2>&1 || { popd >/dev/null; fail ui-build "$LOGDIR/ui-build.log"; exit 1; }
popd >/dev/null
pass ui-build

# ---- 5. tests — CI parity: -v -race ----
# Live-database tests skip unless these are set, and skipping quietly is how
# the differential tests that gate streaming and pushdown correctness went a
# long time without running anywhere but one machine. Provision them the way
# the EE preflight provisions Redis: use what the operator already has,
# otherwise bring up the compose file, otherwise say plainly that the run is
# weaker than it looks.
if [ -z "${BROKOLI_TEST_POSTGRES_URL:-}" ] || [ -z "${BROKOLI_TEST_MYSQL_URL:-}" ] || [ -z "${BROKOLI_TEST_CLICKHOUSE_URL:-}" ]; then
  if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
    say "starting test databases (docker-compose.test.yml)"
    docker compose -f docker-compose.test.yml up -d >/dev/null 2>&1 || true
    for _ in $(seq 1 40); do
      healthy=$(docker compose -f docker-compose.test.yml ps --format '{{.Health}}' 2>/dev/null | grep -c healthy || true)
      [ "$healthy" -ge 3 ] && break
      sleep 3
    done
    : "${BROKOLI_TEST_POSTGRES_URL:=postgres://brokoli:brokoli@localhost:55532/brokoli_test?sslmode=disable}"
    : "${BROKOLI_TEST_MYSQL_URL:=mysql://brokoli:br@k:li/pw#1@tcp(localhost:55533)/brokoli_test}"
    : "${BROKOLI_TEST_MYSQL_ROOT_URL:=mysql://root:rootpw@tcp(localhost:55533)/brokoli_test}"
    : "${BROKOLI_TEST_CLICKHOUSE_URL:=clickhouse://brokoli:brokoli@localhost:55534/brokoli_test}"
    # The azure_blob tests opt into loopback per test, so unlike the S3
    # ones they need no process-wide policy override and can run here too.
    : "${BROKOLI_TEST_AZURE_BLOB_ENDPOINT:=http://127.0.0.1:55537/devstoreaccount1}"
    export BROKOLI_TEST_POSTGRES_URL BROKOLI_TEST_MYSQL_URL BROKOLI_TEST_MYSQL_ROOT_URL BROKOLI_TEST_CLICKHOUSE_URL BROKOLI_TEST_AZURE_BLOB_ENDPOINT
  else
    say "WARNING: no docker compose — live-database tests will SKIP, and they are"
    say "         the ones that check what a real server does with a literal."
  fi
fi

# -timeout 15m, not Go's 10m default: a package measured 581s under -race
# on a loaded machine (Tnsor-Labs/brokoli#329), so the default panics
# under load and blames whichever test held the baton.
#
# The engine package is excluded here and run below with its own budget,
# because that is what .github/workflows/ci.yml does: CI runs
# `$(go list ./... | grep -v '/engine$')` at 15m and gives engine a
# separate job at -timeout 25m. This script ran everything at 15m while
# its comment claimed CI parity, so preflight failed on a package CI was
# giving 10 more minutes -- measured at 1037s (17m17s) on an idle
# machine, which is over the old ceiling before any load at all. The
# panic then named whichever test held the baton, which is the exact
# misdiagnosis the 15m ceiling was introduced to stop.
stage "go test -race ./... minus engine"
# Full output to a log, not just a tail: piping straight into `tail`
# throws away the "--- FAIL" lines and the failing package name, which
# left a failure showing as a bare "FAIL" with nothing to act on.
if ! go test -race -timeout 15m $(go list ./... | grep -v '/engine$') > "$LOGDIR/tests.log" 2>&1; then
  grep -E "^(FAIL|--- FAIL|panic:)" "$LOGDIR/tests.log" | head -20
  fail tests "$LOGDIR/tests.log"
  exit 1
fi
tail -3 "$LOGDIR/tests.log"
pass tests

# ---- 5a1. the engine package, on CI's budget ----
# Matches the dedicated "Test (engine)" job: -timeout 25m inside a 30m
# job limit. It is the most expensive package in the repo and the only
# one that has ever needed this.
stage "go test -race ./engine/ (the long pole)"
if ! go test -race -timeout 25m ./engine/ > "$LOGDIR/tests-engine.log" 2>&1; then
  grep -E "^(FAIL|--- FAIL|panic:)" "$LOGDIR/tests-engine.log" | head -20
  fail tests-engine "$LOGDIR/tests-engine.log"
  exit 1
fi
tail -3 "$LOGDIR/tests-engine.log"
pass tests-engine

# ---- 5a2. code-node legacy-path leg (ADR-029 dual mode) ----
# The pool is the default; the legacy spawn path stays green until its
# removal one release later. Matches the CI leg.
stage "engine code-node tests, legacy path (BROKOLI_CODE_POOL=0)"
if ! env BROKOLI_CODE_POOL=0 go test -timeout 10m -run 'TestCodeNode|TestLazyRows|TestPipeline_LazyRows|TestPipeline_EmitZeroRows|TestPipeline_Streamed|TestInstanceWorker|TestExpansion' ./engine/ > "$LOGDIR/tests-legacy-code.log" 2>&1; then
  grep -E "^(FAIL|--- FAIL|panic:)" "$LOGDIR/tests-legacy-code.log" | head -10
  fail tests-legacy-code "$LOGDIR/tests-legacy-code.log"
  exit 1
fi
pass tests-legacy-code

# ---- 5b. code-node wrapper contract tests (pytest, ADR-029) ----
# Real Python tested as Python, matching CI. Skipped only when pytest
# is genuinely unavailable — the wrapper tests matter exactly as much
# as the Go tests that pin the same contract from the other side.
stage "pytest pkg/codeexec/pywrapper/tests"
if python3 -m pytest --version >/dev/null 2>&1; then
  if ! python3 -m pytest pkg/codeexec/pywrapper/tests/ -q > "$LOGDIR/pytest.log" 2>&1; then
    tail -20 "$LOGDIR/pytest.log"
    fail pytest "$LOGDIR/pytest.log"
    exit 1
  fi
  tail -1 "$LOGDIR/pytest.log"
  pass pytest
else
  echo "pytest not installed for python3 — skipping (CI still runs it)"
fi

# ---- 5c. code-node JavaScript wrapper contract tests (ADR-030) ----
stage "node --test pkg/codeexec/jswrapper/tests"
if ! node --test pkg/codeexec/jswrapper/tests/*.test.mjs > "$LOGDIR/jswrapper-node.log" 2>&1; then
  tail -20 "$LOGDIR/jswrapper-node.log"
  fail jswrapper-node "$LOGDIR/jswrapper-node.log"
  exit 1
fi
tail -3 "$LOGDIR/jswrapper-node.log"
pass jswrapper-node

# ---- 6. optional coverage pass (CI runs it; adds minutes; opt-in) ----
if [ "${PREFLIGHT_COVERAGE:-}" = "1" ]; then
  stage "coverage (CI parity)"
  go test -coverprofile="$LOGDIR/coverage.out" ./... > "$LOGDIR/coverage.log" 2>&1
  go tool cover -func="$LOGDIR/coverage.out" | tail -1
  pass coverage
fi

# ---- 7. build + smoke — CI parity ----
stage "CGO_ENABLED=0 build + --help smoke"
CGO_ENABLED=0 go build -tags minicore_disabled -o "$LOGDIR/brokoli-preflight" .
"$LOGDIR/brokoli-preflight" --help >/dev/null
pass build-smoke

# ---- collect parallel stages ----
say "waiting on parallel security scans + UI checks"
wait "$GOSEC_PID" || fail gosec "$LOGDIR/gosec.log"
cat "$LOGDIR/gosec.verdict" 2>/dev/null || true
set +e
wait "$VULN_PID"
vuln_status=$?
set -e
if ! govulncheck_verdict "$vuln_status" "$LOGDIR/govulncheck.log"; then
  FAILED=1
fi
wait "$LIC_PID"   || fail licenses "$LOGDIR/licenses.log"
wait "$UI_CHECK_PID" || fail ui-checks "$LOGDIR/ui-checks.log"
cat "$LOGDIR/ui-checks.verdict" 2>/dev/null || true
[ "$FAILED" = 0 ] && { [ -f "$LOGDIR/gosec.verdict" ] && grep -q SKIP "$LOGDIR/gosec.verdict" || true; }

TOTAL=$(( $(date +%s) - START ))
if [ "$FAILED" = 1 ]; then
  printf '\n\033[31mPREFLIGHT FAILED\033[0m after %ss — fix locally, do not push.\n' "$TOTAL"
  exit 1
fi
printf '\n\033[32mPREFLIGHT GREEN\033[0m in %ss — safe to push.\n' "$TOTAL"