changemaker.lite/scripts/build-release.sh
bunker-admin a531f9b9ce fix(ccp): make agent functional + fix Gitea release timestamp bug
Three related fixes uncovered during a marcelle CCP registration test:

1. ccp-agent image was missing bash + curl + jq + python3, so every
   spawn('bash', ...) in upgrade.routes.ts and backup.routes.ts failed
   silently with ENOENT. CCP kept reading stale status.json files from
   disk, masking that no agent had successfully checked for updates in
   weeks. apk-add the missing tools.

2. ccp-agent's /app/instance mount was :ro, blocking the agent from
   writing data/upgrade/status.json (and result/progress/backups).
   Agent already has docker.sock — removing :ro is not a security
   escalation. Patched both docker-compose.yml and docker-compose.prod.yml.

3. Gitea 1.23.x only initializes Release.CreatedUnix inside its
   createTag() helper, which is skipped if the tag already exists on
   origin. The old DEV_WORKFLOW pattern (push tag, then run
   build-release.sh --upload) was triggering this — releases got
   created_unix=0 and lost /releases/latest sort order to v2.9.14.
   build-release.sh now removes the remote tag first and POSTs with
   target_commitish so Gitea creates the tag and release atomically.

After these fixes, CCP's "Check for Updates" path returns truthful
data end-to-end (verified on marcelle: v2.9.15 -> v2.10.1, 1 behind).

Bunker Admin
2026-05-20 11:59:35 -06:00

337 lines
12 KiB
Bash
Executable File

#!/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
# Gitea 1.23.x only initializes Release.CreatedUnix inside its createTag()
# path. If the git tag already exists on origin when we POST /releases,
# createTag() is skipped and CreatedUnix stays 0, which makes /releases/latest
# silently return an older release. Remove the remote tag first so Gitea
# creates it via target_commitish below. The tag is preserved locally and
# gets recreated at the same SHA — no history is lost.
if git ls-remote --exit-code origin "refs/tags/${TAG}" >/dev/null 2>&1; then
warn "Removing remote tag ${TAG} so Gitea can recreate it (CreatedUnix init)"
git push origin ":refs/tags/${TAG}" >/dev/null 2>&1 || true
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}\",\"target_commitish\":\"${COMMIT_SHA}\",\"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"