The Reconcile tenant API domains step ran on every push to main. On the production host that is actively harmful: api.gorbushka.market already has a hand-written vhost, and configure-api-domain.sh writes its own file per domain - so the step would hand nginx a second server block for a server_name that already has one and re-run certbot against a live API, once per deploy. Shipping frontend files needs none of it. Gate it behind a workflow_dispatch input, off by default, for standing up a NEW base domain. This also shrinks the secrets a normal deploy requires to four (DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS); STOREFRONT_DOMAINS, CERTBOT_EMAIL and BACKEND_UPSTREAM are now read only on the opt-in path. Document the production host as it actually is: provisioned by hand before server-setup.sh existed, per-domain vhosts rooted at /var/www/dexarmarket/browser, which is now a symlink to /srv/marketplaces/current/frontend. Before 2026-08-22 it pointed straight at a pinned release with no `current` in between, so releases14d46ceand98c39f6uploaded successfully and were never served.
197 lines
7.8 KiB
YAML
197 lines
7.8 KiB
YAML
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.<base-domain> 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/<name>/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.<base-domain> 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 <<EOSSH
|
|
BASE=/srv/marketplaces
|
|
REL="\$BASE/releases/$RELEASE"
|
|
test -f "\$REL/frontend/index.html" || { echo "upload incomplete, refusing to swap" >&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
|