223 lines
9.5 KiB
YAML
223 lines
9.5 KiB
YAML
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"
|
|
# platform-ci-base bakes in git/jq/dig/rsync/bun — skip apk on the fast path,
|
|
# fall back to installing only if a tool is missing (e.g. stock docker:24 image).
|
|
command -v git >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 && command -v dig >/dev/null 2>&1 \
|
|
|| apk add --no-cache git jq bind-tools rsync >/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
|
|
|
|
# ---- domain collision pre-check: clear error instead of a raw caddy
|
|
# "ambiguous site definition" when another deploy already serves this domain ----
|
|
for dom in "$DOMAIN" ${ALT:+"$ALT"}; do
|
|
for frag in /srv/platform/caddy/sites/*.caddy; do
|
|
[ -f "$frag" ] || continue
|
|
base=$(basename "$frag")
|
|
{ [ "$base" = "${SLUG}.caddy" ] || [ "$base" = "_platform.caddy" ]; } && continue
|
|
for a in $(sed -n '1,/{/p' "$frag" | tr -d '{' | tr ',' ' '); do
|
|
if [ "$a" = "$dom" ]; then
|
|
echo "::error::domain '$dom' is already served by another deploy ($base). Change DOMAIN in your .env, or ask the admin to free it (remove $base)."
|
|
exit 1
|
|
fi
|
|
done
|
|
done
|
|
done
|
|
|
|
case "$DT" in
|
|
static)
|
|
# build IN-PLACE — the job runs on platform-ci-base which has bun, so no
|
|
# throwaway helper + docker-cp round-trip. (Trade-off: the dev's build runs
|
|
# in the privileged job container; consistent with the Phase-6 socket-accepted
|
|
# model. Revert to the sandboxed helper if that matters more than ~3s.)
|
|
echo "::group::build (bun, in-place)"
|
|
cd /tmp/repo
|
|
if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile; else bun install; fi
|
|
bun run build
|
|
echo "::endgroup::"
|
|
|
|
echo "::group::publish static → /srv/sites/$DOMAIN"
|
|
[ -d "/tmp/repo/${BUILD_DIR}" ] || { echo "::error::build output '${BUILD_DIR}' not found"; exit 1; }
|
|
TMP="/srv/sites/.tmp-${SLUG}-${SHA}"
|
|
rm -rf "$TMP"; mkdir -p "$TMP"
|
|
cp -a "/tmp/repo/${BUILD_DIR}/." "$TMP/"
|
|
rm -rf "/srv/sites/${DOMAIN}"; mv "$TMP" "/srv/sites/${DOMAIN}"
|
|
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 sites --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}"
|