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
337 lines
12 KiB
Bash
Executable File
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"
|