name: Deploy Frontend # Multi-tenant: one bundle serves every customer domain, so a single deploy # updates all of them at once. There is no per-tenant build or per-tenant deploy. on: push: branches: - main workflow_dispatch: inputs: ref: description: Branch or SHA to deploy required: false default: main reconcile_api_domains: description: >- Also provision api. nginx vhosts and TLS. Off by default: existing API domains are configured by hand, and re-running the helper writes a second server block for a server_name that already has one. Turn this on only when adding a NEW base domain. type: boolean required: false default: false concurrency: group: deploy-frontend cancel-in-progress: false # never abandon a half-finished release swap jobs: deploy: runs-on: ubuntu-latest environment: production defaults: run: shell: bash steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 with: node-version: 24 cache: npm - name: Install dependencies run: npm ci - name: Enforce boundaries run: npm run arch:check - name: Build run: npm run build -- --configuration production - name: Resolve build output id: dist run: | set -euo pipefail # @angular/build:application emits into dist//browser. # Fall back to the flat layout so this survives a builder change. if [ -d dist/dexarmarket/browser ]; then DIR=dist/dexarmarket/browser elif [ -f dist/dexarmarket/index.html ]; then DIR=dist/dexarmarket else echo "no build output found under dist/dexarmarket" >&2 ls -R dist || true exit 1 fi test -f "$DIR/index.html" || { echo "$DIR has no index.html" >&2; exit 1; } echo "dir=$DIR" >> "$GITHUB_OUTPUT" echo "Deploying from $DIR ($(find "$DIR" -type f | wc -l) files)" - name: Configure SSH env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} run: | set -euo pipefail test -n "$DEPLOY_SSH_KEY" || { echo "secret DEPLOY_SSH_KEY is empty" >&2; exit 1; } test -n "$DEPLOY_KNOWN_HOSTS" || { echo "secret DEPLOY_KNOWN_HOSTS is empty" >&2; exit 1; } mkdir -p ~/.ssh printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key # Pinned host key, so a MITM or a rebuilt server fails the deploy # instead of being trusted silently. printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts # Opt-in only. api. vhosts already exist and are hand-managed; # the helper writes its own file per domain, so running it unconditionally # would give nginx two server blocks for one server_name and re-run certbot # against a live API on every single deploy. Frontend releases do not need # this step - it is for standing up a NEW base domain. - name: Reconcile tenant API domains if: ${{ inputs.reconcile_api_domains }} env: HOST: ${{ secrets.DEPLOY_HOST }} USER: ${{ secrets.DEPLOY_USER }} STOREFRONT_DOMAINS: ${{ secrets.STOREFRONT_DOMAINS }} CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }} BACKEND_UPSTREAM: ${{ secrets.BACKEND_UPSTREAM }} run: | set -euo pipefail test -n "$HOST" || { echo "secret DEPLOY_HOST is empty" >&2; exit 1; } test -n "$USER" || { echo "secret DEPLOY_USER is empty" >&2; exit 1; } test -n "$STOREFRONT_DOMAINS" || { echo "secret STOREFRONT_DOMAINS is empty" >&2; exit 1; } test -n "$CERTBOT_EMAIL" || { echo "secret CERTBOT_EMAIL is empty" >&2; exit 1; } BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-https://127.0.0.1:445}" [[ "$CERTBOT_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || { echo "CERTBOT_EMAIL is invalid" >&2; exit 1; } [[ "$BACKEND_UPSTREAM" =~ ^https?://[A-Za-z0-9.:-]+$ ]] || { echo "BACKEND_UPSTREAM is invalid" >&2; exit 1; } declare -A API_BASE_DOMAINS=() for storefront in $STOREFRONT_DOMAINS; do [[ "$storefront" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || { echo "invalid storefront domain: $storefront" >&2; exit 1; } IFS=. read -ra labels <<< "$storefront" label_count=${#labels[@]} take=2 tld=${labels[label_count-1]} second_level=${labels[label_count-2]} if (( label_count >= 3 && ${#tld} == 2 && ${#second_level} <= 3 )); then take=3 fi start=$((label_count - take)) base_domain=$(IFS=.; echo "${labels[*]:start}") API_BASE_DOMAINS["$base_domain"]=1 done SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes" for domain in "${!API_BASE_DOMAINS[@]}"; do $SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \ --domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM" done - name: Upload release env: HOST: ${{ secrets.DEPLOY_HOST }} USER: ${{ secrets.DEPLOY_USER }} SRC: ${{ steps.dist.outputs.dir }} run: | set -euo pipefail RELEASE="${GITHUB_SHA::12}" echo "RELEASE=$RELEASE" >> "$GITHUB_ENV" SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes" $SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend" tar -C "$SRC" -czf - . | $SSH "$USER@$HOST" \ "tar -xzf - -C /srv/marketplaces/releases/$RELEASE/frontend" - name: Activate release env: HOST: ${{ secrets.DEPLOY_HOST }} USER: ${{ secrets.DEPLOY_USER }} run: | set -euo pipefail ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" bash -euo pipefail <&2; exit 1; } # ln -T onto a temp name then mv: the swap is atomic, so no request # is ever served from a half-updated root. ln -sfnT "\$REL" "\$BASE/current.new" mv -Tf "\$BASE/current.new" "\$BASE/current" sudo /bin/systemctl reload nginx # Keep the last 5 releases so a rollback is a symlink change. ls -1dt "\$BASE"/releases/*/ | tail -n +6 | xargs -r rm -rf echo "active: \$(readlink -f \$BASE/current)" EOSSH - name: Verify env: HOST: ${{ secrets.DEPLOY_HOST }} USER: ${{ secrets.DEPLOY_USER }} run: | set -euo pipefail ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" \ 'curl -fsS -o /dev/null -w "health=%{http_code}\n" http://127.0.0.1/health && curl -fsS -o /dev/null -w "index=%{http_code}\n" http://127.0.0.1/' - name: Report if: always() run: | if [ "${{ job.status }}" = "success" ]; then echo "Deployed ${GITHUB_SHA::12} to ${{ secrets.DEPLOY_HOST }}" >> "$GITHUB_STEP_SUMMARY" else echo "Deploy of ${GITHUB_SHA::12} FAILED. The previous release is still active — the symlink only moves after a successful upload." >> "$GITHUB_STEP_SUMMARY" fi