open-navigator / packages /hosting /scripts /huggingface /deploy-huggingface.sh
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
19.4 kB
#!/bin/bash
# Quick deployment script for Hugging Face Spaces
# Deploys all three apps: Documentation, Frontend, and API
#
# Pre-deployment checks:
# 1. Docusaurus build verification (catches config errors early)
# 2. Docker build test (validates full deployment)
#
# Usage:
# ./deploy-huggingface.sh # Deploy with all tests
# ./deploy-huggingface.sh --skip-test # Skip tests (not recommended)
set -e
# --- Run in an isolated clone so the LIVE serving checkout is never touched ----
# This script does destructive git surgery (orphan branch, `rm --cached`, autostash,
# force-push). The deployment panel/orchestrator launches it from the SAME checkout
# the dev servers (API :8001, Vite :5173) run from, so doing that surgery in place
# yanks files out from under the running site and crashes it. Re-exec inside a
# throwaway local clone instead: the deploy publishes committed `main` (not your
# uncommitted work), so a `--local` clone reproduces exactly what would be deployed
# while leaving the live checkout β€” and the running servers β€” completely alone.
if [ -z "$HF_DEPLOY_ISOLATED" ]; then
SRC_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || SRC_ROOT=""
if [ -n "$SRC_ROOT" ]; then
CLONE_DIR=$(mktemp -d -t hf-deploy-XXXXXX)
trap 'rm -rf "$CLONE_DIR" 2>/dev/null || true' EXIT
echo "🧬 Isolating deploy in a throwaway clone (live checkout untouched):"
echo " $CLONE_DIR"
echo ""
# --local hardlinks objects (fast, cheap) and carries all committed history.
# Skip LFS smudge: deploy strips public/ binaries anyway, and a missing LFS
# object on GitHub (e.g. web_app/public/wikicommons/*.png) aborts checkout.
GIT_LFS_SKIP_SMUDGE=1 git clone --quiet --local "$SRC_ROOT" "$CLONE_DIR"
# Carry over gitignored .env so HF_USERNAME / HF_TOKEN are available in the clone.
[ -f "$SRC_ROOT/.env" ] && cp "$SRC_ROOT/.env" "$CLONE_DIR/.env"
cd "$CLONE_DIR"
set +e
# The throwaway clone has no .venv, so the child can't `source
# .venv/bin/activate` to find the `hf` CLI β€” it would fall back to a bare
# `pip install huggingface-hub`, which aborts on Debian's PEP-668
# "externally-managed" system Python. Carry the host venv's bin onto the
# child's PATH so hf (and its python/pip) resolve directly, no install needed.
_CHILD_PATH="$PATH"
[ -d "$SRC_ROOT/.venv/bin" ] && _CHILD_PATH="$SRC_ROOT/.venv/bin:$PATH"
HF_DEPLOY_ISOLATED=1 PATH="$_CHILD_PATH" bash "packages/hosting/scripts/huggingface/deploy-huggingface.sh" "$@"
rc=$?
set -e
exit $rc
fi
fi
# Load environment variables from .env file if it exists
if [ -f ".env" ]; then
echo "πŸ“ Loading environment variables from .env..."
set -a # automatically export all variables
source .env
set +a
echo ""
fi
# Parse command line arguments
SKIP_TEST=false
while [[ $# -gt 0 ]]; do
case $1 in
--skip-test)
SKIP_TEST=true
shift
;;
*)
HF_USERNAME_ARG="$1"
shift
;;
esac
done
echo "πŸš€ Open Navigator - Hugging Face Deployment"
echo "==========================================================="
echo ""
# Check if HF username is provided (env var or argument)
if [ -z "$HF_USERNAME" ] && [ -z "$HF_USERNAME_ARG" ]; then
echo "❌ Error: Hugging Face username required"
echo ""
echo "Usage Option 1 (.env file - RECOMMENDED):"
echo " Add to .env file: HF_USERNAME=your_username"
echo " ./deploy-huggingface.sh"
echo ""
echo "Usage Option 2 (Environment Variable):"
echo " export HF_USERNAME=your_username"
echo " ./deploy-huggingface.sh"
echo ""
echo "Usage Option 3 (Command Argument):"
echo " ./deploy-huggingface.sh YOUR_HF_USERNAME"
echo ""
echo "Usage Option 4 (Skip Docker test - not recommended):"
echo " ./deploy-huggingface.sh YOUR_HF_USERNAME --skip-test"
echo ""
echo "Example:"
echo " echo 'HF_USERNAME=CommunityOne' >> .env"
echo " ./deploy-huggingface.sh"
echo ""
exit 1
fi
# Use argument if provided, otherwise use env var
if [ -n "$HF_USERNAME_ARG" ]; then
HF_USERNAME="$HF_USERNAME_ARG"
fi
# Deploy to the Space with custom domain configured
SPACE_NAME="open-navigator"
HF_REPO="https://huggingface.co/spaces/${HF_USERNAME}/${SPACE_NAME}"
HF_REMOTE="hf-www" # Use hf-www remote for custom domain Space
echo "πŸ“‹ Deployment Configuration"
echo " Username: $HF_USERNAME"
echo " Space: $SPACE_NAME"
echo " Remote: $HF_REMOTE"
echo " URL: $HF_REPO"
echo " Custom Domain: https://www.communityone.com"
echo ""
# Activate virtual environment if it exists
if [ -d ".venv" ]; then
echo "πŸ”§ Activating virtual environment..."
source .venv/bin/activate
fi
# Check if huggingface-hub is installed
if ! command -v hf &> /dev/null; then
echo "πŸ“¦ Installing huggingface-hub..."
# Debian/Ubuntu system Python is PEP-668 "externally managed", so a bare
# `pip install` aborts with externally-managed-environment. The project venv
# (carried onto PATH by the isolation re-exec) normally already has `hf`; this
# only runs on a bare host, so fall back to --break-system-packages.
pip install huggingface-hub 2>/dev/null \
|| pip install --break-system-packages huggingface-hub
fi
# Authenticate with HuggingFace
echo "πŸ” Checking Hugging Face authentication..."
if ! hf whoami &> /dev/null; then
# Not logged in - try to login with token from .env
if [ -n "$HF_TOKEN" ]; then
echo "πŸ”‘ Logging in with HF_TOKEN from .env..."
if hf auth login --token "$HF_TOKEN" --add-to-git-credential; then
echo "βœ… Successfully authenticated with token from .env"
else
echo "❌ Failed to authenticate with HF_TOKEN"
echo "Please check your token in .env file"
exit 1
fi
else
echo "❌ Not logged in to Hugging Face"
echo ""
echo "Option 1: Add HF_TOKEN to .env file (RECOMMENDED)"
echo " Get token from: https://huggingface.co/settings/tokens"
echo " Add to .env: HF_TOKEN=hf_..."
echo ""
echo "Option 2: Login manually"
echo " hf auth login"
echo ""
exit 1
fi
else
echo "βœ… Already authenticated as: $(hf whoami)"
fi
echo ""
# Clean up old Docker artifacts to prevent disk space issues
echo "🧹 Cleaning up old Docker artifacts..."
docker stop open-navigator-test-container 2>/dev/null || true
docker rm open-navigator-test-container 2>/dev/null || true
docker rmi open-navigator-hf-test 2>/dev/null || true
echo ""
# Verify Docusaurus build before Docker (faster feedback on config errors)
echo "πŸ“š Verifying Docusaurus build..."
echo "This catches configuration errors before the slow Docker build"
echo ""
if [ -d "web_docs/node_modules" ]; then
echo "βœ… Node modules already installed"
else
echo "πŸ“¦ Installing web_docs dependencies..."
cd web_docs
npm ci --prefer-offline --no-audit || npm install --prefer-offline --no-audit
cd ..
fi
echo ""
echo "πŸ”¨ Building documentation site..."
if (cd web_docs && npm run build); then
echo ""
echo "βœ… Docusaurus build succeeded!"
echo ""
else
echo ""
echo "❌ Docusaurus build failed!"
echo ""
echo "Common issues:"
echo " - Duplicate plugin configurations (e.g., gtag in both preset and themeConfig)"
echo " - Invalid frontmatter in .md files"
echo " - Broken internal links"
echo " - Missing dependencies"
echo ""
echo "Fix the errors above before deploying."
echo "Test locally with: cd web_docs && npm run build"
echo ""
exit 1
fi
# Run Docker build test before deployment (unless skipped)
if [ "$SKIP_TEST" = true ]; then
echo "⚠️ Skipping pre-deployment Docker build test (--skip-test flag)"
echo ""
else
echo "πŸ§ͺ Running pre-deployment Docker build test..."
echo "This ensures the build works before pushing to Hugging Face"
echo ""
if [ -f "./test-huggingface-build.sh" ]; then
chmod +x ./test-huggingface-build.sh
if ./test-huggingface-build.sh; then
echo ""
echo "βœ… Pre-deployment test passed!"
echo ""
else
echo ""
echo "❌ Pre-deployment test failed!"
echo ""
echo "Please fix the Docker build issues before deploying."
echo "Run './test-huggingface-build.sh' to test locally."
echo ""
echo "To deploy anyway (not recommended), use:"
echo " ./deploy-huggingface.sh $HF_USERNAME --skip-test"
echo ""
exit 1
fi
else
echo "⚠️ Warning: test-huggingface-build.sh not found"
echo "Skipping pre-deployment test"
echo ""
fi
fi
# Ask to create space if it doesn't exist
echo "🌟 Creating Hugging Face Space (if it doesn't exist)..."
# hf CLI (huggingface_hub >= 0.30) uses `--repo-type` + `--space_sdk` with a
# positional repo_id; the old `--type`/`--space-sdk` flags were huggingface-cli.
hf repo create "${HF_USERNAME}/${SPACE_NAME}" --repo-type space --space_sdk docker --exist-ok || true
echo ""
# Update cache-bust timestamps to force fresh build
echo "πŸ”„ Updating cache-bust timestamps to force fresh build..."
TIMESTAMP=$(date +%Y-%m-%d-%H-%M)
COMMIT_HASH=$(git rev-parse --short HEAD)
CACHE_BUST="${TIMESTAMP}-${COMMIT_HASH}"
echo " Timestamp: $TIMESTAMP"
echo " Commit: $COMMIT_HASH"
echo " Cache-bust: $CACHE_BUST"
# Update Docusaurus cache-bust
sed -i.bak "s/ARG CACHE_BUST=.*/ARG CACHE_BUST=${CACHE_BUST}/" Dockerfile
sed -i.bak "s/echo \"Cache bust: .*/echo \"Cache bust: ${CACHE_BUST}\" \&\&/" Dockerfile
# Update Frontend cache-bust
sed -i.bak "s/ARG CACHE_BUST_FRONTEND=.*/ARG CACHE_BUST_FRONTEND=${CACHE_BUST}/" Dockerfile
sed -i.bak "s/echo \"Frontend build cache bust: .*/echo \"Frontend build cache bust: \$CACHE_BUST_FRONTEND\" \&\& npm run build/" Dockerfile
# Remove backup files
rm -f Dockerfile.bak
echo "βœ… Cache-bust timestamps updated to: $CACHE_BUST"
echo ""
# Create deployment branch
echo "πŸ”§ Preparing deployment branch (clean, no binary history)..."
# This script does heavy git surgery (orphan branch, `rm --cached`, force-push)
# and the deployment orchestrator launches it from whatever branch you're on β€”
# frequently with uncommitted changes. A bare `git checkout main` aborts on a
# dirty tree, so record where we started, stash any local work (incl. untracked),
# and restore it on exit even if a later step fails.
ORIGINAL_REF=$(git symbolic-ref --short -q HEAD || git rev-parse HEAD)
STASH_REF=""
if ! git diff --quiet || ! git diff --cached --quiet \
|| [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo "πŸ“¦ Stashing local changes before deployment branch surgery..."
git stash push --include-untracked -m "deploy-huggingface autostash" >/dev/null
STASH_REF=$(git rev-parse -q --verify "stash@{0}")
fi
restore_working_state() {
local rc=$?
echo ""
echo "♻️ Restoring original working state (${ORIGINAL_REF})..."
# The orphan-branch surgery leaves a staged/half-conflicted index, so a plain
# `git checkout` refuses to switch and silently leaves us stranded on the
# deploy branch. Hard-reset the index/worktree first, THEN force back to the
# original ref β€” this works no matter what broken state a crash left behind.
git reset -q --hard >/dev/null 2>&1 || true
git checkout --force "$ORIGINAL_REF" >/dev/null 2>&1 || true
# Drop the throwaway deploy branch so the next run starts from a clean slate.
git branch -D huggingface-deploy >/dev/null 2>&1 || true
if [ -n "$STASH_REF" ]; then
if git stash pop >/dev/null 2>&1; then
echo "βœ… Restored your branch (${ORIGINAL_REF}) and uncommitted changes."
else
echo "⚠️ Could not auto-pop the deploy stash; recover with: git stash list && git stash pop"
fi
else
echo "βœ… Restored your branch (${ORIGINAL_REF})."
fi
return $rc
}
trap restore_working_state EXIT
# Make sure we're on main
git checkout main
# Create a new orphan branch (no history) to avoid binary file issues
git branch -D huggingface-deploy 2>/dev/null || true
git checkout --orphan huggingface-deploy
# Copy Dockerfile for HF (they look for "Dockerfile" not "Dockerfile.huggingface")
echo "πŸ“ Configuring Dockerfile..."
cp Dockerfile.huggingface Dockerfile
# Copy README for Space description
echo "πŸ“ Configuring README..."
cp .huggingface/README.md README_HF.md
# Remove large binary files from being staged
# (HF Spaces rejects large binary files in git)
echo "πŸ“ Optimizing deployment (excluding binary files)..."
# Reset index to avoid staging unwanted files
git rm -rf --cached . 2>/dev/null || true
# Add deployment config files (small, safe to force)
git add -f Dockerfile README_HF.md .huggingface/ .gitignore .dockerignore
# Add source code WITHOUT -f to respect .gitignore (excludes node_modules automatically)
# Only add directories that exist β€” some top-level trees were relocated into packages/
# during the scripts/ -> packages/ refactor, and `git add` aborts (exit 128) on any
# pathspec that matches zero files.
for d in agents api config discovery extraction packages pipeline scripts tests visualization \
databricks examples models neon notebooks; do
[ -d "$d" ] && git add "$d"
done
# Add top-level files. Use nullglob so unmatched globs expand to nothing
# (a bare `git add *.yaml` with no match would otherwise abort with exit 128),
# and guard each literal so a since-removed file (e.g. INTEL_ARC_QUICKSTART.md)
# can't fail the whole deploy.
shopt -s nullglob
for f in requirements*.txt *.sh *.md *.yml *.yaml \
setup.py main.py Makefile \
CITATIONS.md CONTRIBUTING.md LICENSE INTEL_ARC_QUICKSTART.md; do
[ -e "$f" ] && git add "$f"
done
shopt -u nullglob
# Add web_app/web_docs source EXCLUDING node_modules (gitignore handles this)
echo "🧹 Adding web_app/web_docs sources (node_modules auto-excluded by .gitignore)..."
git add web_app/ web_docs/
# Force-add the runtime static data the SPA fetches at /data/* (census-map choropleth
# metrics + trends, jurisdiction_mapping_quality, zcta geometry). The whole tree lives
# under the broad `data/` .gitignore rule, so the plain `git add web_app/` above silently
# skips it β€” which means production nginx serves the SPA index.html fallback for every
# /data/*.json request, the frontend's fetch().json() chokes on HTML, and maps render
# with NO shading. Force-staging here is the fix. All files are JSON < 10 MiB, so the
# oversized/binary drop passes below leave them intact.
if [ -d web_app/public/data ]; then
echo "πŸ—ΊοΈ Force-adding web_app/public/data (gitignored static map data)..."
git add -f web_app/public/data/
fi
# Verify node_modules are NOT staged
NODE_MODULES_COUNT=$(git diff --cached --name-only | grep "node_modules" | wc -l)
if [ "$NODE_MODULES_COUNT" -gt 0 ]; then
echo "❌ ERROR: node_modules were staged ($NODE_MODULES_COUNT files)"
echo "This should not happen. Check .gitignore configuration."
exit 1
fi
echo "βœ… Verified: No node_modules in staging area"
# HF Spaces reject non-LFS files > 10 MiB (pre-receive hook). Drop any oversized
# staged file so the push isn't declined β€” this is a "clean deployment without
# binary files", so large assets (e.g. api/static/wikicommons/*.jpg) don't belong.
echo "🧹 Dropping any staged files larger than 10 MiB (HF hard limit)..."
_oversized=0
while IFS= read -r f; do
[ -n "$f" ] || continue
sz=$(git cat-file -s ":$f" 2>/dev/null || echo 0)
if [ "$sz" -gt 10485760 ]; then
echo " ⚠️ Excluding $((sz / 1048576)) MB file: $f"
git rm --cached --quiet -- "$f" 2>/dev/null || true
_oversized=$((_oversized + 1))
fi
done < <(git diff --cached --name-only)
echo "βœ… Oversized files excluded: ${_oversized}"
# HF (Xet) rejects RAW binary files of any size β€” to keep them you'd need LFS/Xet.
# This is a "clean deployment without binary files", and every binary asset here
# lives under public/ or static/ (served by path, never import-ed into the build),
# so dropping them is build-safe; only some runtime images/favicon go missing.
# git flags binary blobs as '-\t-' in numstat β€” use that, extension-agnostic.
echo "🧹 Dropping staged binary files (HF Xet rejects raw binaries)..."
_bin=0
while IFS=$'\t' read -r add del path; do
if [ "$add" = "-" ] && [ "$del" = "-" ] && [ -n "$path" ]; then
git rm --cached --quiet -- "$path" 2>/dev/null || true
_bin=$((_bin + 1))
fi
done < <(git diff --cached --numstat)
echo "βœ… Binary files excluded: ${_bin}"
echo "πŸ’Ύ Committing clean deployment (no git history)..."
git commit -m "Clean HuggingFace deployment without binary files" --allow-empty
# Push to Hugging Face β€” token-authenticated, NON-interactive.
# Without an embedded token git falls back to an interactive credential prompt
# (e.g. VSCode's GIT_ASKPASS asking "Username for https://huggingface.co"), which
# hangs FOREVER in a non-TTY deploy job. Require HF_TOKEN, embed it in the push
# URL, and disable any prompt so a missing/invalid token fails fast instead.
if [ -z "$HF_TOKEN" ]; then
echo "❌ Error: HF_TOKEN is not set β€” cannot push to the Space non-interactively."
echo " Add HF_TOKEN to .env (or the environment) and re-run."
exit 1
fi
export GIT_TERMINAL_PROMPT=0
export GIT_ASKPASS=/bin/echo
export GIT_CONFIG_PARAMETERS="'credential.helper='" # ignore any interactive helper
PUSH_URL="https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/${SPACE_NAME}"
echo ""
echo "πŸ“€ Pushing to Hugging Face Spaces..."
echo "This will trigger a build (takes ~10-15 minutes)"
echo ""
git push "$PUSH_URL" huggingface-deploy:main --force
echo ""
echo "βœ… Deployment initiated!"
echo ""
echo "==========================================================="
echo "πŸŽ‰ Next Steps:"
echo "==========================================================="
echo ""
echo "1. View your Space:"
echo " https://huggingface.co/spaces/${HF_USERNAME}/${SPACE_NAME}"
echo ""
echo "2. Configure hardware (REQUIRED for Docker):"
echo " - Go to Settings β†’ Resource configuration"
echo " - Select 'CPU Basic' (~\$22/month minimum)"
echo ""
echo "3. Add API keys as secrets:"
echo " - Go to Settings β†’ Variables and secrets"
echo " - Add these secrets:"
echo " β€’ OPENAI_API_KEY"
echo " β€’ ANTHROPIC_API_KEY"
echo " β€’ HF_TOKEN"
echo ""
echo "4. Monitor build progress:"
echo " - Click 'Logs' tab in your Space"
echo " - Build takes ~10-15 minutes"
echo ""
echo "5. Access your apps:"
echo " - Main App: https://www.communityone.com/"
echo " - Documentation: https://www.communityone.com/docs/"
echo " - API: https://www.communityone.com/api/docs"
echo ""
echo " (Also available at: https://${HF_USERNAME}-${SPACE_NAME//./-}.hf.space/)"
echo ""
echo "==========================================================="
echo ""
echo "πŸ“– Full guide: ./HUGGINGFACE_DEPLOYMENT.md"
echo ""