Some checks failed
Architecture Governance / architecture (push) Failing after 6m16s
Reconcile TLS, exact CORS, and backend proxying before release activation so every storefront uses its derived API host.
167 lines
6.4 KiB
YAML
167 lines
6.4 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
|
|
|
|
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
|
|
|
|
- name: Reconcile tenant 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;
|
|
}
|
|
|
|
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
|
for domain in $STOREFRONT_DOMAINS; do
|
|
[[ "$domain" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
|
|
echo "invalid storefront domain: $domain" >&2; exit 1;
|
|
}
|
|
$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
|