Files
sdarbinyan 846004e6d8
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
ci(deploy): make API-domain reconciliation opt-in, document the real host layout
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 releases 14d46ce and
98c39f6 uploaded successfully and were never served.
2026-08-22 16:08:13 +04:00

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