name: "platform-deploy" description: "Build & deploy a dev project (static / docker / docker-db) onto the platform" inputs: repo: description: "owner/repo" required: true ref: description: "branch name" required: true sha: description: "commit sha" required: false default: "" token: description: "forge token for checkout" required: true secrets_json: description: "toJSON(secrets)" required: false default: "{}" vars_json: description: "toJSON(vars)" required: false default: "{}" runs: using: composite steps: - shell: sh env: IN_REPO: ${{ inputs.repo }} IN_REF: ${{ inputs.ref }} IN_SHA: ${{ inputs.sha }} IN_TOKEN: ${{ inputs.token }} SJSON: ${{ inputs.secrets_json }} VJSON: ${{ inputs.vars_json }} run: | set -eu FORGE_HOST="git.154.83.149.72.nip.io" OUR_IP="154.83.149.72" echo "::group::setup" apk add --no-cache git jq bind-tools >/dev/null 2>&1 || true REPO="$IN_REPO"; REF="$IN_REF"; SHA="${IN_SHA:-manual}" SLUG=$(printf '%s' "$REPO" | tr 'A-Z/' 'a-z-') FRAG="/srv/platform/caddy/sites/${SLUG}.caddy" echo "repo=$REPO ref=$REF slug=$SLUG" echo "::endgroup::" echo "::group::checkout" rm -rf /tmp/repo git clone --depth 1 -b "$REF" \ "https://x-access-token:${IN_TOKEN}@${FORGE_HOST}/${REPO}.git" /tmp/repo cd /tmp/repo [ -f package.json ] || { echo "::error::no package.json in repo root"; exit 1; } [ -f .env ] || { echo "::error::no .env in repo root (need DOMAIN=...)"; exit 1; } echo "::endgroup::" echo "::group::parse .env" : > /tmp/envp while IFS='=' read -r k v; do case "$k" in ''|\#*) continue ;; esac v=$(printf '%s' "$v" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/") printf '%s=%s\n' "$k" "$v" >> /tmp/envp done < .env set -a; . /tmp/envp; set +a echo "::endgroup::" # ---- kill-switch: repo variable ENABLED overrides .env ---- ADMIN_ENABLED=$(printf '%s' "$VJSON" | jq -r '.ENABLED // empty') EN="${ENABLED:-true}"; [ -n "$ADMIN_ENABLED" ] && EN="$ADMIN_ENABLED" DOMAIN="${DOMAIN:-}" reload_caddy() { docker exec caddy caddy reload --config /etc/caddy/Caddyfile } write_fragment() { # $1 = new content NEW="$1" if [ -f "$FRAG" ] && [ "$(cat "$FRAG")" = "$NEW" ]; then echo " caddy fragment unchanged; no reload"; return 0 fi PREV=""; [ -f "$FRAG" ] && PREV=$(cat "$FRAG") printf '%s' "$NEW" > "$FRAG" if reload_caddy; then echo " caddy reloaded ($FRAG)" else echo "::error::caddy reload failed — reverting fragment" if [ -n "$PREV" ]; then printf '%s' "$PREV" > "$FRAG"; else rm -f "$FRAG"; fi reload_caddy || true exit 1 fi } # ---- disabled path ---- if [ "$EN" = "false" ]; then echo "🛑 ENABLED=false → tearing down $SLUG" docker rm -f "$SLUG" >/dev/null 2>&1 && echo " container removed" || echo " no container" [ -n "$DOMAIN" ] && rm -rf "/srv/sites/${DOMAIN}" if [ -f "$FRAG" ]; then rm -f "$FRAG"; reload_caddy || true; echo " fragment removed + caddy reloaded"; fi echo "✅ $SLUG disabled (404)" exit 0 fi [ -n "$DOMAIN" ] || { echo "::error::DOMAIN not set in .env"; exit 1; } # ---- deploy type ---- DT="${DEPLOY:-}" if [ -z "$DT" ]; then if [ -f Dockerfile ]; then DT=docker; else DT=static; fi fi BUILD_DIR="${BUILD_DIR:-dist}" APP_PORT="${APP_PORT:-80}" echo "deploy_type=$DT domain=$DOMAIN build_dir=$BUILD_DIR" # ---- DNS sanity (warn only) ---- if ! dig +short A "$DOMAIN" 2>/dev/null | grep -q "$OUR_IP"; then echo "::warning::$DOMAIN does not resolve to $OUR_IP yet — TLS issuance will retry once DNS is set" fi # ---- auto-www ---- SITE_ADDR="$DOMAIN"; ALT="" WWW_MODE="${WWW:-auto}" if [ "$WWW_MODE" != "false" ]; then case "$DOMAIN" in www.*) CAND="${DOMAIN#www.}" ;; *.*.*) CAND="" ;; *.*) CAND="www.${DOMAIN}" ;; *) CAND="" ;; esac if [ -n "$CAND" ] && dig +short A "$CAND" 2>/dev/null | grep -q "$OUR_IP"; then ALT="$CAND"; SITE_ADDR="$DOMAIN, $CAND"; echo " auto-www: + $CAND" elif [ -n "$CAND" ]; then echo " auto-www: $CAND has no A→$OUR_IP, serving $DOMAIN only" fi fi case "$DT" in static) echo "::group::build (bun)" BH="build-${SLUG}-${SHA}" docker rm -f "$BH" >/dev/null 2>&1 || true trap 'docker rm -f "$BH" >/dev/null 2>&1 || true' EXIT docker run -d --name "$BH" -w /app oven/bun:1-alpine sleep 1200 >/dev/null docker cp /tmp/repo/. "$BH:/app/" if docker exec -w /app "$BH" sh -c '[ -f bun.lock ] || [ -f bun.lockb ]'; then docker exec -w /app "$BH" sh -c 'bun install --frozen-lockfile' else docker exec -w /app "$BH" sh -c 'bun install' fi docker exec -w /app "$BH" sh -c 'bun run build' echo "::endgroup::" echo "::group::publish static → /srv/sites/$DOMAIN" TMP="/srv/sites/.tmp-${SLUG}-${SHA}" rm -rf "$TMP"; mkdir -p "$TMP" docker cp "$BH:/app/${BUILD_DIR}/." "$TMP/" \ || { echo "::error::build output '${BUILD_DIR}' not found"; exit 1; } rm -rf "/srv/sites/${DOMAIN}"; mv "$TMP" "/srv/sites/${DOMAIN}" docker rm -f "$BH" >/dev/null 2>&1 || true; trap - EXIT echo " $(find "/srv/sites/${DOMAIN}" -type f | wc -l) files published" echo "::endgroup::" write_fragment "$(printf '%s {\n\troot * /srv/sites/%s\n\tfile_server\n\ttry_files {path} /index.html\n}\n' "$SITE_ADDR" "$DOMAIN")" # Remove any prior container LAST — zero-downtime: a pre-existing # container kept serving the domain (via its old fragment) all through # the build above, until the file_server fragment took over just now. docker rm -f "$SLUG" >/dev/null 2>&1 && echo " removed old container (switched to static)" || true ;; docker|docker-db) rm -rf "/srv/sites/${DOMAIN}" # in case it was previously a static deploy [ -f Dockerfile ] || { echo "::error::DEPLOY=$DT needs a Dockerfile in repo root"; exit 1; } echo "::group::docker build" IMG="platform/${SLUG}:${SHA}" docker build -t "$IMG" -t "platform/${SLUG}:latest" /tmp/repo echo "::endgroup::" echo "::group::run container" # runtime secrets: only keys listed in .env RUNTIME_KEYS, pulled from secrets_json RTENV="/tmp/runtime.env"; : > "$RTENV" RK="${RUNTIME_KEYS:-}" OLDIFS=$IFS; IFS=', ' for key in $RK; do [ -z "$key" ] && continue val=$(printf '%s' "$SJSON" | jq -r --arg k "$key" '.[$k] // empty') if [ -n "$val" ]; then printf '%s=%s\n' "$key" "$val" >> "$RTENV"; echo " + runtime env: $key"; fi done IFS=$OLDIFS set -- --name "$SLUG" --network web --restart unless-stopped --env-file "$RTENV" \ --label "platform.project=${REPO}" --label "platform.slug=${SLUG}" --label "platform.domain=${DOMAIN}" if [ "$DT" = "docker-db" ]; then printf '%s\n' "DATABASE_URL=file:/data/app.db" >> "$RTENV" set -- "$@" -v "${SLUG}-data:/data" echo " + sqlite volume ${SLUG}-data, DATABASE_URL=file:/data/app.db" fi docker rm -f "$SLUG" >/dev/null 2>&1 || true docker run -d "$@" "$IMG" >/dev/null rm -f "$RTENV" echo " container $SLUG up → port $APP_PORT" echo "::endgroup::" write_fragment "$(printf '%s {\n\treverse_proxy %s:%s\n}\n' "$SITE_ADDR" "$SLUG" "$APP_PORT")" ;; *) echo "::error::unknown DEPLOY=$DT (use static | docker | docker-db)"; exit 1 ;; esac echo "✅ deployed https://${DOMAIN}${ALT:+ + https://$ALT}"