Procházet zdrojové kódy

CI: Railway production at api.unacms.app, PR previews on unacms.app

Adds a Railway deployment next to the Jenkins one (ci.una.io is
unchanged):

- deploy-production: every push to master deploys the tested package
  to the una-ci production environment (persistent MariaDB and storage
  volumes), served at api.unacms.app.
- PR previews keep their pr-<n> environments and also get
  api-pr-<n>.unacms.app plus a NEO client at pr-<n>.unacms.app (the
  same-named neo-ci branch build when there is one, else production).
  Teardown removes the DNS records and the alias.
- scripts/railway/domains.sh: Railway custom domain + its DNS records
  on Vercel DNS (unacms.app), and neo-ci aliases. Idempotent.
- configure.php (every start): API on, key and origins from env,
  same API options as api.neo.so; unsafe services (raw SQL) off.
- seed.php (UNA_DEMO_SEED=1): 5 people, a group, 6 posts with comments,
  created through module services. Skips when already seeded.
- Image: Debian ffmpeg instead of the bundled static build, which
  segfaults on URL inputs; no error emails from containers.

Secrets: VERCEL_TOKEN, UNA_API_KEY, UNA_PREVIEW_API_KEY (all set).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko před 2 týdny
rodič
revize
e7bf989941

+ 115 - 2
.github/workflows/ci.yml

@@ -100,6 +100,11 @@ jobs:
       RAILWAY_BASE_ENVIRONMENT: ${{ secrets.RAILWAY_ENVIRONMENT }}
       RAILWAY_TEMPLATE_ENVIRONMENT: pr-template
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
+      # unacms.app hosts: the backend on Railway, the NEO client (neo-ci on Vercel) in front of it
+      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+      PREVIEW_API_HOST: api-pr-${{ github.event.pull_request.number }}.unacms.app
+      PREVIEW_CLIENT_HOST: pr-${{ github.event.pull_request.number }}.unacms.app
+      UNA_PREVIEW_API_KEY: ${{ secrets.UNA_PREVIEW_API_KEY }}
     steps:
     - uses: actions/checkout@v4
 
@@ -114,7 +119,8 @@ jobs:
         unzip -q packages/*.zip -d /tmp/una-pkg
         pkg_dir=$(find /tmp/una-pkg -mindepth 1 -maxdepth 1 -type d | head -n 1)
         cp -a "$pkg_dir"/. deploy/
-        cp scripts/railway/Dockerfile scripts/railway/entrypoint.sh scripts/railway/railway.toml scripts/railway/.railwayignore deploy/
+        cp scripts/railway/Dockerfile scripts/railway/entrypoint.sh scripts/railway/railway.toml scripts/railway/.railwayignore \
+          scripts/railway/configure.php scripts/railway/seed.php deploy/
         chmod +x deploy/entrypoint.sh
 
     - name: Install Railway CLI
@@ -154,6 +160,9 @@ jobs:
             MYSQLDATABASE=una \
             MYSQLUSER=una \
             MYSQLPASSWORD=una \
+            UNA_DEMO_SEED=1 \
+            UNA_CLIENT_URL="https://$PREVIEW_CLIENT_HOST" \
+            ${UNA_PREVIEW_API_KEY:+UNA_API_KEY="$UNA_PREVIEW_API_KEY"} \
             && break
           sleep 5
         done
@@ -162,8 +171,19 @@ jobs:
     - name: Deploy artefact
       run: railway up ./deploy --path-as-root --no-gitignore --verbose --ci --service una --environment "$RAILWAY_PR_ENVIRONMENT" --project "$RAILWAY_PROJECT_ID"
 
+    - name: Preview domains on unacms.app
+      id: domains
+      if: env.VERCEL_TOKEN != ''
+      run: |
+        scripts/railway/domains.sh api-domain "$PREVIEW_API_HOST" "$RAILWAY_PR_ENVIRONMENT"
+        # The client build of the same-named neo-ci branch when there is one, else production.
+        # Either way NEO's proxy maps pr-<n>.unacms.app to api-pr-<n>.unacms.app.
+        scripts/railway/domains.sh client-alias "$PREVIEW_CLIENT_HOST" "${{ github.head_ref }}"
+        echo "ok=1" >> "$GITHUB_OUTPUT"
+
     - name: Comment preview URL on PR
       env:
+        DOMAINS_OK: ${{ steps.domains.outputs.ok }}
         GH_TOKEN: ${{ github.token }}
         PR_NUMBER: ${{ github.event.pull_request.number }}
       run: |
@@ -184,7 +204,12 @@ jobs:
         echo "Preview URL: $url"
 
         marker="<!-- railway-preview -->"
-        body=$(printf '%s\n**Preview**: %s\n\nRailway environment: `%s`' "$marker" "$url" "$RAILWAY_PR_ENVIRONMENT")
+        if [ "$DOMAINS_OK" = "1" ]; then
+          body=$(printf '%s\n**Preview**: https://%s (NEO client)\n**API**: https://%s (also %s)\n\nRailway environment: `%s`. A new custom domain needs a few minutes for its certificate.' \
+            "$marker" "$PREVIEW_CLIENT_HOST" "$PREVIEW_API_HOST" "$url" "$RAILWAY_PR_ENVIRONMENT")
+        else
+          body=$(printf '%s\n**Preview**: %s\n\nRailway environment: `%s`' "$marker" "$url" "$RAILWAY_PR_ENVIRONMENT")
+        fi
 
         comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
           --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
@@ -196,6 +221,82 @@ jobs:
           gh pr comment "$PR_NUMBER" --body "$body"
         fi
 
+  deploy-production:
+    # Runs next to the Jenkins deploy to ci.una.io, which is unchanged.
+    name: Deploy master to Railway (api.unacms.app)
+    needs: test
+    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+    runs-on: ubuntu-latest
+    concurrency:
+      group: railway-production
+      cancel-in-progress: false
+    environment:
+      name: production
+      url: https://api.unacms.app
+    env:
+      RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }}
+      RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
+      RAILWAY_ENVIRONMENT: production
+      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+      UNA_API_KEY: ${{ secrets.UNA_API_KEY }}
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/download-artifact@v4
+      with:
+        name: una-package
+        path: packages
+
+    - name: Prepare artefact
+      run: |
+        mkdir -p deploy
+        unzip -q packages/*.zip -d /tmp/una-pkg
+        pkg_dir=$(find /tmp/una-pkg -mindepth 1 -maxdepth 1 -type d | head -n 1)
+        cp -a "$pkg_dir"/. deploy/
+        cp scripts/railway/Dockerfile scripts/railway/entrypoint.sh scripts/railway/railway.toml scripts/railway/.railwayignore \
+          scripts/railway/configure.php scripts/railway/seed.php deploy/
+        chmod +x deploy/entrypoint.sh
+
+    - name: Install Railway CLI
+      run: |
+        curl -fsSL https://railway.com/install.sh | sh
+        echo "$HOME/.railway/bin" >> "$GITHUB_PATH"
+
+    - name: Configure production
+      run: |
+        if [ -z "$RAILWAY_API_TOKEN" ] || [ -z "$RAILWAY_PROJECT_ID" ]; then
+          echo "Set secrets RAILWAY_API_TOKEN (account token) and RAILWAY_PROJECT_ID" >&2
+          exit 1
+        fi
+        railway link --project "$RAILWAY_PROJECT_ID" --environment "$RAILWAY_ENVIRONMENT" --json
+        # Database credentials and UNA_HASH_SECRET live in the Railway environment, not here.
+        railway variable set --service una --environment "$RAILWAY_ENVIRONMENT" --skip-deploys \
+          UNA_CLIENT_URL=https://unacms.app \
+          ${UNA_API_KEY:+UNA_API_KEY="$UNA_API_KEY"}
+
+    - name: Deploy artefact
+      run: railway up ./deploy --path-as-root --no-gitignore --verbose --ci --service una --environment "$RAILWAY_ENVIRONMENT" --project "$RAILWAY_PROJECT_ID"
+
+    - name: api.unacms.app DNS
+      if: env.VERCEL_TOKEN != ''
+      run: scripts/railway/domains.sh api-domain api.unacms.app "$RAILWAY_ENVIRONMENT"
+
+    - name: Smoke test
+      run: |
+        for i in $(seq 1 30); do
+          code=$(curl -s -o /dev/null -w '%{http_code}' https://api.unacms.app/ || true)
+          [ "$code" = "200" ] && { echo "api.unacms.app is up"; exit 0; }
+          # Until the certificate is issued, check the Railway hostname instead.
+          host=$(railway domain list --service una --environment "$RAILWAY_ENVIRONMENT" --json | jq -r '.domains[] | select(.type == "service") | .domain' | head -n1)
+          if [ -n "$host" ] && [ "$(curl -s -o /dev/null -w '%{http_code}' "https://$host/" || true)" = "200" ]; then
+            echo "up at https://$host (api.unacms.app answered $code; certificate may still be pending)"
+            exit 0
+          fi
+          sleep 10
+        done
+        echo "UNA did not come up" >&2
+        exit 1
+
   teardown:
     name: Remove PR Railway environment
     if: github.event_name == 'pull_request' && github.event.action == 'closed'
@@ -205,7 +306,19 @@ jobs:
       RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
       RAILWAY_BASE_ENVIRONMENT: ${{ secrets.RAILWAY_ENVIRONMENT }}
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
+      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
     steps:
+    - uses: actions/checkout@v4
+      with:
+        sparse-checkout: scripts/railway
+
+    - name: Remove preview domains
+      if: env.VERCEL_TOKEN != ''
+      continue-on-error: true
+      run: |
+        scripts/railway/domains.sh client-unalias "pr-${{ github.event.pull_request.number }}.unacms.app"
+        scripts/railway/domains.sh dns-remove "api-pr-${{ github.event.pull_request.number }}.unacms.app"
+
     - name: Install Railway CLI
       run: |
         curl -fsSL https://railway.com/install.sh | sh

+ 11 - 2
scripts/railway/Dockerfile

@@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
         libjpeg62-turbo-dev \
         libpng-dev \
         libzip-dev \
+        ffmpeg \
     && docker-php-ext-configure gd --with-freetype --with-jpeg \
     && docker-php-ext-install -j$(nproc) gd mysqli pdo_mysql zip exif opcache \
     && a2enmod rewrite deflate expires \
@@ -19,10 +20,19 @@ RUN printf '%s\n' \
         'variables_order=EGPCS' \
         > /usr/local/etc/php/conf.d/una.ini
 
+# The bundled static plugins/ffmpeg/ffmpeg.exe segfaults on URL inputs (it resolves
+# hostnames with static glibc), which broke every video poster. Use Debian's ffmpeg.
+# Error reports by email are for ci.una.io; a preview would mail on every request.
+ENV UNA_FFMPEG_PATH=/usr/bin/ffmpeg \
+    UNA_DEBUG_EMAIL_REPORT=0
+
 COPY --chown=www-data:www-data . /var/www/html
 
 RUN mv /var/www/html/entrypoint.sh /usr/local/bin/entrypoint.sh \
     && chmod +x /usr/local/bin/entrypoint.sh \
+    && mkdir -p /usr/local/lib/una-railway \
+    && mv /var/www/html/configure.php /var/www/html/seed.php /usr/local/lib/una-railway/ \
+    && rm -rf /var/www/html/plugins/ffmpeg \
     && rm -f /var/www/html/Dockerfile /var/www/html/railway.toml \
     && mkdir -p /var/www/html/cache /var/www/html/cache_public /var/www/html/logs /var/www/html/tmp /var/www/html/storage \
     && chown -R www-data:www-data \
@@ -31,8 +41,7 @@ RUN mv /var/www/html/entrypoint.sh /usr/local/bin/entrypoint.sh \
         /var/www/html/cache_public \
         /var/www/html/logs \
         /var/www/html/tmp \
-        /var/www/html/storage \
-    && if [ -f /var/www/html/plugins/ffmpeg/ffmpeg.exe ]; then chmod 0755 /var/www/html/plugins/ffmpeg/ffmpeg.exe; fi
+        /var/www/html/storage
 
 WORKDIR /var/www/html
 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

+ 94 - 0
scripts/railway/configure.php

@@ -0,0 +1,94 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Railway: post-install settings for an API-backed site (NEO client in front).
+ * Runs from entrypoint.sh on every container start, after install, so it must be
+ * idempotent. Plain mysqli on purpose: no UNA bootstrap, nothing cached yet.
+ *
+ * Environment:
+ *   DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD  set by entrypoint.sh
+ *   UNA_API_KEY      key the NEO server proxy sends as `Authorization: Bearer`
+ *   UNA_API_ORIGINS  comma-separated browser origins allowed to call the API directly
+ *   UNA_CLIENT_URL   public client URL (links in emails and push notifications)
+ *
+ * Mirrors the "Database Configuration" and "New API Setup" stages of scripts/Jenkinsfile,
+ * except that unsafe API services (raw SQL through r=q) stay off.
+ */
+
+$oDb = new mysqli(
+    getenv('DB_HOST'),
+    getenv('DB_USER'),
+    getenv('DB_PASSWORD'),
+    getenv('DB_NAME'),
+    (int)(getenv('DB_PORT') ?: 3306)
+);
+$oDb->set_charset('utf8mb4');
+
+function setOption(mysqli $oDb, string $sName, string $sValue): void
+{
+    $oStmt = $oDb->prepare('UPDATE `sys_options` SET `value` = ? WHERE `name` = ?');
+    $oStmt->bind_param('ss', $sValue, $sName);
+    $oStmt->execute();
+}
+
+function listFromEnv(string $sName): array
+{
+    return array_values(array_filter(array_map('trim', explode(',', (string)getenv($sName)))));
+}
+
+$sClientUrl = rtrim((string)getenv('UNA_CLIENT_URL'), '/');
+
+$aOptions = [
+    // API: the NEO proxy authenticates with a key; browsers (Expo web, local dev) by origin.
+    'sys_api_enable' => 'on',
+    'sys_api_access_by_key' => 'on',
+    'sys_api_access_by_origin' => 'on',
+    'sys_api_access_unsafe_services' => '',
+    'sys_session_auth' => 'on',
+    'sys_api_cookie_path' => '/',
+    'sys_api_cookie_samesite' => 'Lax',
+    'sys_api_cookie_secure' => 'on',
+    // Same API behaviour as api.neo.so.
+    'sys_api_search_sections' => 'bx_posts,bx_persons,bx_groups,bx_events,bx_spaces,bx_market,bx_organizations,bx_ads,bx_videos,sys_pages,bx_timeline',
+    'sys_api_conn_in_prof_units' => 'on',
+    'sys_api_context_connection' => 'subscriptions',
+    'sys_api_context_switcher' => 'bx_spaces',
+    'sys_api_root_page_guest' => 'splash',
+    'sys_api_root_page_member' => 'home',
+    'sys_iconset_default' => 'sys_lucide',
+];
+if ($sClientUrl !== '') {
+    $aOptions['sys_api_url_root_email'] = $sClientUrl . '/';
+    $aOptions['sys_api_url_root_push'] = $sClientUrl . '/';
+}
+foreach ($aOptions as $sName => $sValue)
+    setOption($oDb, $sName, $sValue);
+
+// Same "tmp fix for search via API" as the Jenkinsfile.
+$oDb->query("UPDATE `sys_objects_search` SET `GlobalSearch` = '0' WHERE `ObjectName` IN ('sys_pages', 'bx_timeline')");
+
+if (($sKey = trim((string)getenv('UNA_API_KEY'))) !== '') {
+    $oStmt = $oDb->prepare("INSERT INTO `sys_api_keys` (`title`, `key`, `order`) VALUES ('NEO', ?, 1) ON DUPLICATE KEY UPDATE `title` = `title`");
+    $oStmt->bind_param('s', $sKey);
+    $oStmt->execute();
+}
+
+$aOrigins = listFromEnv('UNA_API_ORIGINS');
+if ($sClientUrl !== '')
+    $aOrigins[] = $sClientUrl;
+foreach (array_unique($aOrigins) as $iOrder => $sOrigin) {
+    $oStmt = $oDb->prepare('SELECT 1 FROM `sys_api_origins` WHERE `url` = ?');
+    $oStmt->bind_param('s', $sOrigin);
+    $oStmt->execute();
+    if ($oStmt->get_result()->num_rows)
+        continue;
+
+    $oStmt = $oDb->prepare('INSERT INTO `sys_api_origins` (`url`, `order`) VALUES (?, ?)');
+    $iPos = $iOrder + 1;
+    $oStmt->bind_param('si', $sOrigin, $iPos);
+    $oStmt->execute();
+}
+
+echo "API configured" . ($sClientUrl !== '' ? " for $sClientUrl" : '') . "\n";

+ 128 - 0
scripts/railway/domains.sh

@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+# Railway custom domains on unacms.app (DNS hosted by Vercel) and NEO client aliases.
+# Used by .github/workflows/ci.yml; every subcommand is idempotent.
+#
+#   domains.sh api-domain <host> <railway-environment>   custom domain for service `una`, plus its DNS records
+#   domains.sh dns-remove <host>                         delete the records api-domain created for <host>
+#   domains.sh client-alias <host> [branch]              point <host> at the NEO client build of <branch>
+#                                                        (latest ready preview), or production when there is none
+#   domains.sh client-unalias <host>
+#
+# Environment: VERCEL_TOKEN, RAILWAY_PROJECT_ID (api-domain), RAILWAY_API_TOKEN (railway CLI),
+#   DNS_ZONE (default unacms.app), CLIENT_PROJECT (default neo-ci).
+set -euo pipefail
+
+DNS_ZONE="${DNS_ZONE:-unacms.app}"
+CLIENT_PROJECT="${CLIENT_PROJECT:-neo-ci}"
+RAILWAY_SERVICE="${RAILWAY_SERVICE:-una}"
+VERCEL_API=https://api.vercel.com
+
+die() { echo "domains.sh: $*" >&2; exit 1; }
+[ -n "${VERCEL_TOKEN:-}" ] || die "VERCEL_TOKEN is not set"
+
+vercel() { # vercel <METHOD> <path> [json-body]
+    local method=$1 path=$2 body=${3:-}
+    local sep='?'; case "$path" in *\?*) sep='&' ;; esac
+    local url="$VERCEL_API$path${TEAM_ID:+${sep}teamId=$TEAM_ID}"
+    if [ -n "$body" ]; then
+        curl -fsS -X "$method" -H "Authorization: Bearer $VERCEL_TOKEN" -H 'Content-Type: application/json' -d "$body" "$url"
+    else
+        curl -fsS -X "$method" -H "Authorization: Bearer $VERCEL_TOKEN" "$url"
+    fi
+}
+
+# The Vercel team (or personal account) that owns DNS_ZONE; it also owns the client project.
+TEAM_ID=""
+resolve_team() {
+    local team
+    for team in "" $(TEAM_ID="" vercel GET /v2/teams | jq -r '.teams[].id'); do
+        if TEAM_ID="$team" vercel GET "/v5/domains/$DNS_ZONE" >/dev/null 2>&1; then
+            TEAM_ID="$team"
+            return
+        fi
+    done
+    die "no Vercel team with access to $DNS_ZONE"
+}
+
+label_of() { # api-pr-12.unacms.app -> api-pr-12
+    local host=$1
+    [ "${host%."$DNS_ZONE"}" != "$host" ] || die "$host is not in $DNS_ZONE"
+    echo "${host%."$DNS_ZONE"}"
+}
+
+dns_upsert() { # dns_upsert <name> <type> <value>
+    local name=$1 type=$2 value=$3 current
+    current=$(vercel GET "/v4/domains/$DNS_ZONE/records?limit=100" \
+        | jq -r --arg n "$name" --arg t "$type" '.records[] | select(.name == $n and .type == $t) | "\(.id) \(.value)"')
+    while read -r id val; do
+        [ -n "$id" ] || continue
+        if [ "$val" = "$value" ]; then echo "dns: $type $name ok"; return; fi
+        vercel DELETE "/v2/domains/$DNS_ZONE/records/$id" >/dev/null
+    done <<< "$current"
+    vercel POST "/v2/domains/$DNS_ZONE/records" \
+        "$(jq -nc --arg n "$name" --arg t "$type" --arg v "$value" '{name: $n, type: $t, value: $v, ttl: 60}')" >/dev/null
+    echo "dns: $type $name -> $value"
+}
+
+dns_remove() { # dns_remove <name>  (also its _railway-verify TXT)
+    local name=$1
+    vercel GET "/v4/domains/$DNS_ZONE/records?limit=100" \
+        | jq -r --arg n "$name" --arg v "_railway-verify.$name" '.records[] | select(.name == $n or .name == $v) | .id' \
+        | while read -r id; do
+            vercel DELETE "/v2/domains/$DNS_ZONE/records/$id" >/dev/null && echo "dns: removed record $id"
+        done
+}
+
+api_domain() { # api_domain <host> <environment>
+    local host=$1 env=$2 label id status
+    label=$(label_of "$host")
+    [ -n "${RAILWAY_PROJECT_ID:-}" ] || die "RAILWAY_PROJECT_ID is not set"
+
+    railway domain "$host" --service "$RAILWAY_SERVICE" --environment "$env" --port 80 --json >/dev/null 2>&1 || true
+    id=$(railway domain list --service "$RAILWAY_SERVICE" --environment "$env" --json \
+        | jq -r --arg h "$host" '.domains[] | select(.type == "custom" and .domain == $h) | .id')
+    [ -n "$id" ] || die "Railway did not create $host"
+
+    status=$(railway api 'query($id: String!, $projectId: String!) { customDomain(id: $id, projectId: $projectId) { status { verificationDnsHost verificationToken dnsRecords { hostlabel recordType requiredValue } } } }' \
+        --variables "$(jq -nc --arg id "$id" --arg p "$RAILWAY_PROJECT_ID" '{id: $id, projectId: $p}')")
+
+    jq -r '.data.customDomain.status.dnsRecords[] | "\(.hostlabel) \(.recordType | sub("DNS_RECORD_TYPE_"; "")) \(.requiredValue)"' <<< "$status" \
+        | while read -r name type value; do dns_upsert "$name" "$type" "$value"; done
+
+    local vhost vtoken
+    vhost=$(jq -r '.data.customDomain.status.verificationDnsHost // empty' <<< "$status")
+    vtoken=$(jq -r '.data.customDomain.status.verificationToken // empty' <<< "$status")
+    [ -z "$vhost" ] || dns_upsert "$vhost" TXT "$vtoken"
+    echo "api: https://$host ($label)"
+}
+
+client_alias() { # client_alias <host> [branch]
+    local host=$1 branch=${2:-} project uid=""
+    project=$(vercel GET "/v9/projects/$CLIENT_PROJECT" | jq -r '.id')
+    if [ -n "$branch" ]; then
+        uid=$(vercel GET "/v6/deployments?projectId=$project&state=READY&limit=100" \
+            | jq -r --arg b "$branch" '[.deployments[] | select(.meta.githubCommitRef == $b)] | sort_by(.created) | last | .uid // empty')
+    fi
+    if [ -n "$uid" ]; then
+        echo "client: $CLIENT_PROJECT branch $branch"
+    else
+        uid=$(vercel GET "/v6/deployments?projectId=$project&target=production&state=READY&limit=1" | jq -r '.deployments[0].uid // empty')
+        [ -n "$uid" ] || die "no ready production deployment of $CLIENT_PROJECT"
+        echo "client: $CLIENT_PROJECT production"
+    fi
+    vercel POST "/v2/deployments/$uid/aliases" "$(jq -nc --arg a "$host" '{alias: $a}')" >/dev/null
+    echo "client: https://$host"
+}
+
+client_unalias() {
+    vercel DELETE "/v2/aliases/$1" >/dev/null 2>&1 && echo "client: removed $1" || echo "client: $1 had no alias"
+}
+
+resolve_team
+case "${1:-}" in
+    api-domain)     api_domain "${2:?host}" "${3:?environment}" ;;
+    dns-remove)     dns_remove "$(label_of "${2:?host}")" ;;
+    client-alias)   client_alias "${2:?host}" "${3:-}" ;;
+    client-unalias) client_unalias "${2:?host}" ;;
+    *) die "usage: domains.sh api-domain|dns-remove|client-alias|client-unalias ..." ;;
+esac

+ 9 - 3
scripts/railway/entrypoint.sh

@@ -13,9 +13,6 @@ a2enmod mpm_prefork rewrite >/dev/null 2>&1 || true
 
 mkdir -p cache cache_public logs tmp storage
 chmod a+rwX inc cache cache_public logs tmp storage
-if [ -f plugins/ffmpeg/ffmpeg.exe ]; then
-    chmod 0755 plugins/ffmpeg/ffmpeg.exe
-fi
 
 parse_mysql_url() {
     _url="$1"
@@ -123,6 +120,15 @@ else
     fi
 fi
 
+# API key, origins and client URL for the NEO front end (idempotent, every start).
+php /usr/local/lib/una-railway/configure.php
+
+if [ "${UNA_DEMO_SEED:-0}" = "1" ]; then
+    echo "Seeding demo content..."
+    # A failed seed must not keep the site down; the log says what went wrong.
+    php /usr/local/lib/una-railway/seed.php || echo "Demo seed failed (exit $?)" >&2
+fi
+
 chown -R www-data:www-data inc cache cache_public logs tmp storage
 
 exec "$@"

+ 139 - 0
scripts/railway/seed.php

@@ -0,0 +1,139 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Railway: demo content for a fresh install, so production and previews are not empty.
+ * Runs from entrypoint.sh when UNA_DEMO_SEED=1. Idempotent: skips everything once the
+ * first demo account exists. Content goes through module services (not raw SQL), so
+ * timeline events, counters and notifications are created the same way as by users.
+ *
+ * Environment:
+ *   UNA_DEMO_PASSWORD  password for the demo accounts (random if unset)
+ */
+
+ob_start(); // bx_login() sets cookies; nothing to send them to in CLI
+
+$_SERVER['HTTP_HOST'] = getenv('RAILWAY_PUBLIC_DOMAIN') ?: 'localhost';
+$_SERVER['REQUEST_URI'] = '/';
+$_SERVER['SERVER_NAME'] = $_SERVER['HTTP_HOST'];
+$GLOBALS['bx_profiler_disable'] = true;
+
+require_once('/var/www/html/inc/header.inc.php');
+
+function seedLog(string $s): void
+{
+    fwrite(STDERR, "[seed] $s\n");
+}
+
+$aUsers = [
+    ['name' => 'Lily Carter', 'email' => 'lily@example.com'],
+    ['name' => 'Eric Novak', 'email' => 'eric@example.com'],
+    ['name' => 'Mila Santos', 'email' => 'mila@example.com'],
+    ['name' => 'Tom Walsh', 'email' => 'tom@example.com'],
+    ['name' => 'Ella Kim', 'email' => 'ella@example.com'],
+];
+
+if (BxDolAccount::getInstance($aUsers[0]['email'])) {
+    seedLog('demo content already present, skipping');
+    exit(0);
+}
+
+$sPassword = getenv('UNA_DEMO_PASSWORD') ?: bin2hex(random_bytes(12));
+$oAccountForms = bx_instance('BxTemplAccountForms');
+
+// --- people: account + person profile each
+$aProfiles = []; // [account_id, person profile id]
+foreach ($aUsers as $aUser) {
+    $a = $oAccountForms->createAccount([
+        'name' => $aUser['name'],
+        'email' => $aUser['email'],
+        'password' => $sPassword,
+        'email_confirmed' => 1,
+    ], BX_PROFILE_ACTION_AUTO, false);
+    if (empty($a['account_id'])) {
+        seedLog("account {$aUser['email']} failed: " . ($a['error'] ?? 'unknown'));
+        continue;
+    }
+
+    $aFields = bx_srv('bx_persons', 'prepare_fields', [['author' => $a['profile_id'], 'name' => $aUser['name']]]);
+    $aFields['allow_view_to'] = BX_DOL_PG_ALL;
+    $r = bx_srv('bx_persons', 'entity_add_forcedly', [$a['profile_id'], $aFields]);
+    if (empty($r['content']['profile_id'])) {
+        seedLog("person {$aUser['name']} failed: " . ($r['message'] ?? 'unknown'));
+        continue;
+    }
+
+    $aProfiles[] = [$a['account_id'], (int)$r['content']['profile_id']];
+    seedLog("person {$aUser['name']}");
+}
+
+if (!$aProfiles) {
+    seedLog('no profiles created, stopping');
+    exit(1);
+}
+
+/** Act as a demo user, so ACL checks and authorship match a real post. */
+function seedAs(array $aProfile): int
+{
+    bx_login($aProfile[0], false);
+    return $aProfile[1];
+}
+
+// --- a public group
+$iGroupProfileId = 0;
+$iOwner = seedAs($aProfiles[0]);
+$r = bx_srv('bx_groups', 'entity_add_forcedly', [$iOwner, [
+    'group_name' => 'UNA Community',
+    'group_desc' => 'Introduce yourself, share what you are building with UNA and NEO, and ask questions.',
+    'allow_view_to' => BX_DOL_PG_ALL,
+]]);
+if (!empty($r['content']['profile_id'])) {
+    $iGroupProfileId = (int)$r['content']['profile_id'];
+    seedLog('group UNA Community');
+}
+
+// --- posts, each with a couple of comments from other people
+$aPosts = [
+    ['Hello from the demo site', "This site is a fresh UNA install deployed from the master branch. Everything here is sample content."],
+    ['What NEO is', "NEO is the universal UNA client: one React Native codebase for web, iOS and Android, talking to UNA through its API."],
+    ['Preview environments', "Every pull request to UNA gets its own backend and client on unacms.app, created by CI and removed when the PR closes."],
+    ['Weekend reading', "A few articles about building communities that stay useful: keep threads short, answer quickly, and welcome new members by name."],
+    ['Photo walk this Saturday', "Meeting at the old harbour at 10:00. Bring any camera; phones are fine. Coffee afterwards."],
+    ['Tips for a good profile', "Add a picture, write two sentences about what you do, and follow a few groups. That's enough to get a useful feed."],
+];
+$aComments = [
+    'Great to see this running.',
+    'Thanks for sharing!',
+    'Count me in.',
+    'Very helpful, bookmarking this.',
+];
+
+foreach ($aPosts as $i => [$sTitle, $sText]) {
+    $aAuthor = $aProfiles[$i % count($aProfiles)];
+    $iAuthor = seedAs($aAuthor);
+    $r = bx_srv('bx_posts', 'entity_add_forcedly', [$iAuthor, [
+        'title' => $sTitle,
+        'text' => '<p>' . $sText . '</p>',
+        'allow_view_to' => BX_DOL_PG_ALL,
+    ]]);
+    if (empty($r['content']['id'])) {
+        seedLog("post '$sTitle' failed: " . ($r['message'] ?? 'unknown'));
+        continue;
+    }
+    seedLog("post '$sTitle'");
+
+    if (!($oCmts = BxDolCmts::getObjectInstance('bx_posts', (int)$r['content']['id'])))
+        continue;
+    for ($j = 1; $j <= 2; $j++) {
+        $aCommenter = $aProfiles[($i + $j) % count($aProfiles)];
+        $oCmts->add([
+            'cmt_author_id' => seedAs($aCommenter),
+            'cmt_parent_id' => 0,
+            'cmt_text' => $aComments[($i + $j) % count($aComments)],
+        ]);
+    }
+}
+
+seedLog('done: ' . count($aProfiles) . ' people, ' . ($iGroupProfileId ? 1 : 0) . ' group, ' . count($aPosts) . ' posts');
+ob_end_clean();