#!/usr/bin/env bash # ============================================================================= # Changemaker Lite V2 — Build Release Tarball # # Creates a lightweight release tarball (~1-2 MB) containing only runtime files # needed to deploy with pre-built Docker images. No source code included. # # Usage: # ./scripts/build-release.sh [OPTIONS] # # Options: # --tag TAG Version tag (default: git describe or commit SHA) # --output DIR Output directory (default: ./releases/) # --upload Upload to Gitea Releases API after building # --replace Delete + recreate an existing release with this tag # (DESTRUCTIVE: users already on TAG see no upgrade signal) # --dry-run Show what would be included without creating tarball # --help Show this help # # Prerequisites: # Run ./scripts/build-and-push.sh first to push Docker images to registry. # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # --- Defaults --- TAG="" OUTPUT_DIR="${PROJECT_DIR}/releases" UPLOAD=false DRY_RUN=false REPLACE=false # --- Colors --- if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' NC='' fi info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # --- Arg parser --- while [[ $# -gt 0 ]]; do case "$1" in --tag) TAG="$2"; shift 2 ;; --output) OUTPUT_DIR="$2"; shift 2 ;; --upload) UPLOAD=true; shift ;; --replace) REPLACE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --help|-h) sed -n '2,20p' "$0" | grep '^#' | sed 's/^# \?//' exit 0 ;; *) error "Unknown option: $1"; exit 1 ;; esac done # --- Determine version --- cd "$PROJECT_DIR" if [[ -z "$TAG" ]]; then TAG="$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)" fi COMMIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo -e "${BOLD}Changemaker Lite — Build Release${NC}" echo " Tag: $TAG" echo " Commit: $COMMIT_SHA" echo " Date: $BUILD_DATE" echo "" # --- Create staging directory --- STAGE_DIR="$(mktemp -d)/changemaker-lite" mkdir -p "$STAGE_DIR" # --- Write VERSION file --- cat > "$STAGE_DIR/VERSION" << EOF $TAG $COMMIT_SHA $BUILD_DATE EOF info "VERSION: $TAG ($COMMIT_SHA)" # --- Copy production docker-compose --- if [[ ! -f "$PROJECT_DIR/docker-compose.prod.yml" ]]; then error "docker-compose.prod.yml not found. Generate it first." exit 1 fi # Fail the release if dev and prod compose files have drifted on critical # healthcheck blocks — catches cases where one file was patched without # the other (bit us on the api start_period fix). if [[ -x "$PROJECT_DIR/scripts/validate-compose-parity.sh" ]]; then if ! bash "$PROJECT_DIR/scripts/validate-compose-parity.sh"; then error "Compose parity check failed. Aborting release build." exit 1 fi fi cp "$PROJECT_DIR/docker-compose.prod.yml" "$STAGE_DIR/docker-compose.yml" info "docker-compose.yml (production)" # --- Copy config files --- cp "$PROJECT_DIR/.env.example" "$STAGE_DIR/" cp "$PROJECT_DIR/config.sh" "$STAGE_DIR/" info "Config files (.env.example, config.sh)" # --- Copy scripts --- mkdir -p "$STAGE_DIR/scripts" # Init scripts (from api/prisma/ to scripts/) cp "$PROJECT_DIR/api/prisma/init-nocodb-db.sh" "$STAGE_DIR/scripts/" cp "$PROJECT_DIR/api/prisma/init-gancio-db.sh" "$STAGE_DIR/scripts/" # Explicit allow/deny lists for scripts/*.sh. Every shell script in scripts/ # must appear in exactly one of these arrays — the parity check below aborts # the build if anything is unaccounted for. This catches the "added a new # script, forgot to add it to the release" drift bug. RUNTIME_SCRIPTS=( # Ship in the release tarball — user-facing or invoked by containers/upgrades install.sh nocodb-init.sh gitea-init.sh mkdocs-entrypoint.sh backup.sh restore.sh upgrade.sh upgrade-check.sh upgrade-watcher.sh uninstall.sh test-deployment.sh validate-env.sh pangolin-teardown.sh ccp-deregister.sh register-with-ccp.sh update-env.sh ) DEV_ONLY_SCRIPTS=( # Deliberately NOT shipped — dev-machine tooling (build/release/mirror/CI) build-and-push.sh build-release.sh mirror-images.sh validate-compose-parity.sh ) # --- Parity check: every scripts/*.sh must be classified --- declare -A KNOWN_SCRIPTS for s in "${RUNTIME_SCRIPTS[@]}" "${DEV_ONLY_SCRIPTS[@]}"; do KNOWN_SCRIPTS[$s]=1 done UNCLASSIFIED=() for f in "$PROJECT_DIR"/scripts/*.sh; do [[ -f "$f" ]] || continue name=$(basename "$f") if [[ -z "${KNOWN_SCRIPTS[$name]:-}" ]]; then UNCLASSIFIED+=("$name") fi done if [[ ${#UNCLASSIFIED[@]} -gt 0 ]]; then error "build-release.sh parity check failed — unclassified scripts/*.sh:" for name in "${UNCLASSIFIED[@]}"; do echo " - $name" >&2 done echo "" >&2 echo " Every script in scripts/*.sh must be classified as either:" >&2 echo " RUNTIME_SCRIPTS — ships in the release tarball" >&2 echo " DEV_ONLY_SCRIPTS — stays on dev/build machines only" >&2 echo " Edit scripts/build-release.sh and add each to the correct array." >&2 exit 1 fi # Copy the runtime scripts for script in "${RUNTIME_SCRIPTS[@]}"; do if [[ -f "$PROJECT_DIR/scripts/$script" ]]; then cp "$PROJECT_DIR/scripts/$script" "$STAGE_DIR/scripts/" fi done # MkDocs build trigger (python, not in the allowlist because it's .py) if [[ -f "$PROJECT_DIR/scripts/mkdocs-build-trigger.py" ]]; then cp "$PROJECT_DIR/scripts/mkdocs-build-trigger.py" "$STAGE_DIR/scripts/" fi # Systemd units if [[ -d "$PROJECT_DIR/scripts/systemd" ]]; then cp -r "$PROJECT_DIR/scripts/systemd" "$STAGE_DIR/scripts/" fi chmod +x "$STAGE_DIR/scripts/"*.sh 2>/dev/null || true info "Scripts ($(ls "$STAGE_DIR/scripts/" | wc -l) files)" # --- Copy configs --- if [[ -d "$PROJECT_DIR/configs" ]]; then cp -r "$PROJECT_DIR/configs" "$STAGE_DIR/" # Ensure code-server skeleton dirs exist mkdir -p "$STAGE_DIR/configs/code-server/.config" mkdir -p "$STAGE_DIR/configs/code-server/.local" info "Configs (homepage, prometheus, grafana, alertmanager, pangolin)" fi # --- Copy nginx templates (for reference) --- mkdir -p "$STAGE_DIR/nginx/conf.d" cp "$PROJECT_DIR"/nginx/conf.d/*.template "$STAGE_DIR/nginx/conf.d/" 2>/dev/null || true info "Nginx templates (for reference)" # --- Copy MkDocs starter docs --- if [[ -d "$PROJECT_DIR/mkdocs" ]]; then mkdir -p "$STAGE_DIR/mkdocs" cp "$PROJECT_DIR/mkdocs/mkdocs.yml" "$STAGE_DIR/mkdocs/" 2>/dev/null || true cp -r "$PROJECT_DIR/mkdocs/docs" "$STAGE_DIR/mkdocs/" 2>/dev/null || mkdir -p "$STAGE_DIR/mkdocs/docs" cp -r "$PROJECT_DIR/mkdocs/overrides" "$STAGE_DIR/mkdocs/" 2>/dev/null || mkdir -p "$STAGE_DIR/mkdocs/overrides" mkdir -p "$STAGE_DIR/mkdocs/.cache" mkdir -p "$STAGE_DIR/mkdocs/site" info "MkDocs (starter documentation)" fi # --- Copy public-web --- if [[ -d "$PROJECT_DIR/public-web" ]]; then cp -r "$PROJECT_DIR/public-web" "$STAGE_DIR/" else mkdir -p "$STAGE_DIR/public-web" fi info "Public web assets" # --- Create empty data directories --- mkdir -p "$STAGE_DIR/assets/uploads" mkdir -p "$STAGE_DIR/assets/icons" mkdir -p "$STAGE_DIR/assets/images" mkdir -p "$STAGE_DIR/data/upgrade" mkdir -p "$STAGE_DIR/media/local/inbox" mkdir -p "$STAGE_DIR/media/local/thumbnails" mkdir -p "$STAGE_DIR/media/local/photos" mkdir -p "$STAGE_DIR/media/local/documents" mkdir -p "$STAGE_DIR/media/public" mkdir -p "$STAGE_DIR/local-files" info "Data directories (empty)" # --- Summary --- echo "" STAGE_SIZE=$(du -sh "$STAGE_DIR" | cut -f1) FILE_COUNT=$(find "$STAGE_DIR" -type f | wc -l) info "Staging: ${FILE_COUNT} files, ${STAGE_SIZE}" if [[ "$DRY_RUN" == "true" ]]; then echo "" info "[DRY RUN] Would create: changemaker-lite-${TAG}.tar.gz" find "$STAGE_DIR" -type f | sed "s|$STAGE_DIR/||" | sort rm -rf "$(dirname "$STAGE_DIR")" exit 0 fi # --- Create tarball --- mkdir -p "$OUTPUT_DIR" TARBALL="${OUTPUT_DIR}/changemaker-lite-${TAG}.tar.gz" tar czf "$TARBALL" -C "$(dirname "$STAGE_DIR")" "changemaker-lite" rm -rf "$(dirname "$STAGE_DIR")" TARBALL_SIZE=$(du -h "$TARBALL" | cut -f1) success "Created: $TARBALL (${TARBALL_SIZE})" # --- Upload to Gitea (optional) --- if [[ "$UPLOAD" == "true" ]]; then source "$PROJECT_DIR/.env" 2>/dev/null || true # GITEA_REGISTRY_API_TOKEN is for the remote registry (gitea.bnkops.com) # GITEA_API_TOKEN is for the local platform Gitea — do NOT use it here GITEA_TOKEN="${GITEA_REGISTRY_API_TOKEN:-}" # GITEA_URL is the internal Docker hostname — use GITEA_REGISTRY for external access GITEA_REGISTRY_HOST="${GITEA_REGISTRY%%/*}" # strip /admin path → gitea.bnkops.com GITEA_HOST="${GITEA_EXTERNAL_URL:-https://${GITEA_REGISTRY_HOST:-gitea.bnkops.com}}" if [[ -z "$GITEA_TOKEN" ]]; then warn "GITEA_REGISTRY_API_TOKEN not set — skipping upload" warn "Set GITEA_REGISTRY_API_TOKEN in .env and re-run with --upload" else # Refuse to overwrite an existing release unless --replace is explicit. # Silently overwriting a published tag breaks upgrade checks for users # already on that version (they see "no update available" even though # the tarball changed). EXISTING_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ "${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases/tags/${TAG}" \ -H "Authorization: token ${GITEA_TOKEN}") if [[ "$EXISTING_RESPONSE" == "200" ]]; then if [[ "$REPLACE" != "true" ]]; then error "Release ${TAG} already exists." error " Bump the version to a new tag, or pass --replace to delete and recreate." error " --replace is destructive: users on ${TAG} will see no upgrade signal but get different tarball contents." exit 1 fi warn "Release ${TAG} exists — deleting first (--replace)" EXISTING_ID=$(curl -sf "${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases/tags/${TAG}" \ -H "Authorization: token ${GITEA_TOKEN}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "") if [[ -n "$EXISTING_ID" ]]; then curl -sf -X DELETE \ "${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases/${EXISTING_ID}" \ -H "Authorization: token ${GITEA_TOKEN}" >/dev/null success "Deleted existing release ${TAG} (id ${EXISTING_ID})" fi fi info "Creating Gitea release ${TAG}..." RELEASE_RESPONSE=$(curl -sf -X POST \ "${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"Changemaker Lite ${TAG}\",\"body\":\"Release ${TAG} (${COMMIT_SHA})\"}" \ 2>/dev/null || true) RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) if [[ -n "$RELEASE_ID" ]]; then info "Uploading tarball to release ${RELEASE_ID}..." curl -sf -X POST \ "${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases/${RELEASE_ID}/assets" \ -H "Authorization: token ${GITEA_TOKEN}" \ -F "attachment=@${TARBALL}" \ >/dev/null 2>&1 success "Uploaded to Gitea release ${TAG}" else warn "Failed to create release — upload manually at ${GITEA_HOST}/admin/changemaker.lite/releases" fi fi fi echo "" success "Release ${TAG} ready." echo " Tarball: $TARBALL" echo " Install: tar xzf $(basename "$TARBALL") && cd changemaker-lite && bash config.sh"