init platform/launch

This commit is contained in:
root 2026-07-17 12:00:36 +00:00
parent 87470cc453
commit 0a8730039f

273
action.yml Normal file
View file

@ -0,0 +1,273 @@
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")"
# ---- optional install-script subdomain: curl get.<domain> | sh ----
# Opt-in via .env INSTALL_SH=<file in build output> (e.g. install.sh).
# Serves that one file as text/plain at get.<domain> (self-managed
# fragment ${SLUG}-get.caddy, separate from the main one). Unset the var
# → the subdomain is removed on the next deploy. Dev must add the DNS
# A-record get.<domain> → our IP themselves.
GFRAG="/srv/platform/caddy/sites/${SLUG}-get.caddy"
if [ -n "${INSTALL_SH:-}" ]; then
GDOM="get.${DOMAIN}"
[ -f "/srv/sites/${DOMAIN}/${INSTALL_SH}" ] \
|| echo "::warning::${INSTALL_SH} not in build output — ${GDOM} will 404 until you add public/${INSTALL_SH}"
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 * /srv/sites/%s\n\trewrite * /%s\n\theader Content-Type text/plain\n\tfile_server\n}\n' "$GDOM" "$DOMAIN" "$INSTALL_SH")
if [ ! -f "$GFRAG" ] || [ "$(cat "$GFRAG")" != "$GNEW" ]; then
printf '%s' "$GNEW" > "$GFRAG"
reload_caddy && echo " install subdomain → https://${GDOM} (serves /${INSTALL_SH})" \
|| { 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"; reload_caddy || true; echo " install subdomain removed (INSTALL_SH unset)"
fi
# 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"
# 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\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
echo "✅ deployed https://${DOMAIN}${ALT:+ + https://$ALT}"