launch/action.yml

288 lines
13 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.extract.team"
OUR_IP="51.161.128.94"
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}"
rm -f "/srv/platform/caddy/sites/${SLUG}-get.caddy" 2>/dev/null || true # install subdomain, if any
[ -f "$FRAG" ] && rm -f "$FRAG"
reload_caddy || true; echo " fragment(s) removed + caddy reloaded"
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
# ---- extra domains (EXTRA_DOMAINS=alias1.com,alias2.com in .env) ----
# Serve the SAME site on additional domains, each with its own auto-TLS cert.
# Point each domain's A-record at the platform IP. www is NOT auto-added for
# extras — list "www.alias.com" explicitly if you want it.
if [ -n "${EXTRA_DOMAINS:-}" ]; then
OLDIFS=$IFS; IFS=', '
for ed in ${EXTRA_DOMAINS}; do
[ -z "$ed" ] && continue
SITE_ADDR="$SITE_ADDR, $ed"
echo " extra domain: + $ed"
dig +short A "$ed" 2>/dev/null | grep -q "$OUR_IP" \
|| echo "::warning::$ed has no A→$OUR_IP yet — add the DNS record; TLS issues once it resolves"
done
IFS=$OLDIFS
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 $(printf '%s' "$SITE_ADDR" | tr ',' ' '); 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\timport accesslog\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}"
# BuildKit ON: enables --mount=type=cache in the repo's Dockerfile and
# layer reuse from the previous :latest (inline cache) → fast rebuilds when
# deps/layers are unchanged. First build of a repo just misses the cache.
DOCKER_BUILDKIT=1 docker build \
--cache-from "platform/${SLUG}:latest" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t "$IMG" -t "platform/${SLUG}:latest" /tmp/repo
echo "::endgroup::"
echo "::group::run container"
# Everything in .env (except platform-control keys) is passed straight into
# the container as env vars — the dev keeps config in .env and just pushes,
# nothing to set up by hand. Forgejo Actions secrets via RUNTIME_KEYS still
# work and are layered on top (and override .env on name clash).
RTENV="/tmp/runtime.env"; : > "$RTENV"
CTRL=" DOMAIN DEPLOY BUILD_DIR APP_PORT WWW EXTRA_DOMAINS TWITTER TWITTER_URL X INSTALL_SH RUNTIME_KEYS ENABLED "
while IFS='=' read -r k v; do
[ -z "$k" ] && continue
case "$CTRL" in *" $k "*) continue ;; esac
printf '%s=%s\n' "$k" "$v" >> "$RTENV"
done < /tmp/envp
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 " + secret from Forgejo: $key"; fi
done
IFS=$OLDIFS
echo " + $(grep -c . "$RTENV" || echo 0) env vars into container (from .env)"
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\timport accesslog\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
# ---- install subdomain (curl get.<domain> | sh) — static OR docker ----
# Opt in via .env INSTALL_SH=<file> (looked up in the repo at root or public/).
# The file is copied to a tiny dir and served as text/plain at get.<domain>,
# regardless of deploy type. Unset INSTALL_SH → the subdomain is removed.
# Dev adds the DNS A-record get.<domain> → our IP themselves.
GFRAG="/srv/platform/caddy/sites/${SLUG}-get.caddy"
GDIR="/srv/sites/.get-${SLUG}"
if [ -n "${INSTALL_SH:-}" ]; then
GDOM="get.${DOMAIN}"; GFILE=$(basename "$INSTALL_SH")
SRC=""
for cand in "/tmp/repo/${INSTALL_SH}" "/tmp/repo/public/${INSTALL_SH}"; do
[ -f "$cand" ] && { SRC="$cand"; break; }
done
rm -rf "$GDIR"; mkdir -p "$GDIR"
if [ -n "$SRC" ]; then cp "$SRC" "$GDIR/$GFILE"
else echo "::warning::${INSTALL_SH} not found in repo (root or public/) — ${GDOM} will 404"; fi
dig +short A "$GDOM" 2>/dev/null | grep -q "$OUR_IP" \
|| echo "::warning::no A record ${GDOM} → ${OUR_IP} yet — add it; TLS issues once it resolves"
GNEW=$(printf '%s {\n\troot * %s\n\trewrite * /%s\n\theader Content-Type text/plain\n\tfile_server\n}\n' "$GDOM" "$GDIR" "$GFILE")
if [ ! -f "$GFRAG" ] || [ "$(cat "$GFRAG")" != "$GNEW" ]; then
printf '%s' "$GNEW" > "$GFRAG"
reload_caddy && echo " install subdomain → https://${GDOM}" \
|| { echo "::error::caddy reload failed for $GFRAG"; rm -f "$GFRAG"; reload_caddy || true; }
else
echo " install subdomain fragment unchanged"
fi
elif [ -f "$GFRAG" ]; then
rm -f "$GFRAG"; rm -rf "$GDIR"; reload_caddy || true; echo " install subdomain removed (INSTALL_SH unset)"
fi
echo "✅ deployed https://${DOMAIN}${ALT:+ + https://$ALT}"