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. | sh) — static OR docker ---- # Opt in via .env INSTALL_SH= (looked up in the repo at root or public/). # The file is copied to a tiny dir and served as text/plain at get., # regardless of deploy type. Unset INSTALL_SH → the subdomain is removed. # Dev adds the DNS A-record get. → 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}"