diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..94e9f0e --- /dev/null +++ b/action.yml @@ -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. | sh ---- + # Opt-in via .env INSTALL_SH= (e.g. install.sh). + # Serves that one file as text/plain at get. (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. → 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}"