From 28861953c8c81c89550634ac326c7fd81c3a3880 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 11:42:06 +0400 Subject: [PATCH] ci: add frontend CD pipeline, server provisioning, TLS scripts There was no CD: pushing to main deployed nothing, and deploys were a manual copy onto the server. This adds the missing half. - .github/workflows/deploy.yml - build, upload to a per-commit release directory, swap the symlink atomically, reload nginx, verify over HTTP. The swap only happens after the upload is verified to contain index.html, so a failed deploy leaves the previous release serving. - scripts/deploy/server-setup.sh - idempotent one-time provisioning: nginx, certbot, ufw, and a key-only deploy user whose sole sudo right is "systemctl reload nginx". - scripts/deploy/add-domain.sh - per-domain server block plus TLS issuance, run once a domain's A record resolves to the server. - docs/DEPLOYMENT.md - setup order, required CI secrets, rollback, limits. Also adds .gitattributes: the shell scripts were being checked out with CRLF endings, which makes bash fail on the shebang line on Linux. Host keys are pinned via DEPLOY_KNOWN_HOSTS rather than trusted on first use. No credentials are committed; all four deploy secrets are supplied by CI. Co-Authored-By: Claude Opus 5 --- .gitattributes | 7 ++ .github/workflows/deploy.yml | 134 +++++++++++++++++++++++++++++ docs/DEPLOYMENT.md | 140 ++++++++++++++++++++++++++++++ scripts/deploy/add-domain.sh | 125 +++++++++++++++++++++++++++ scripts/deploy/server-setup.sh | 153 +++++++++++++++++++++++++++++++++ 5 files changed, 559 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/deploy.yml create mode 100644 docs/DEPLOYMENT.md create mode 100755 scripts/deploy/add-domain.sh create mode 100755 scripts/deploy/server-setup.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4fd0c6b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto + +# Anything executed by a Linux shell must keep LF endings. A CRLF checkout +# makes bash fail with "\r: command not found" on the very first line. +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..fb11d79 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,134 @@ +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 + + 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: 20 + 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 + + - 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" + rsync -az --delete \ + -e "$SSH" \ + "$SRC/" "$USER@$HOST:/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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..45bdb15 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,140 @@ +# Deployment — server provisioning, CD, TLS + +Frontend only. The backend service (`:8080`) is a separate developer's responsibility; nginx already proxies `/api/` to it and will `502` until it exists. + +**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-HANDOFF.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy. + +--- + +## 1. Files + +| Path | Purpose | +|---|---| +| `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. | +| `scripts/deploy/add-domain.sh` | Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. | +| `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. | + +--- + +## 2. Layout on the server + +``` +/srv/marketplaces/ +├── releases/ +│ ├── a1b2c3d4e5f6/frontend/ <- one directory per deployed commit +│ └── ... (last 5 kept) +└── current -> releases/a1b2c3d4e5f6 +``` + +nginx root is `/srv/marketplaces/current/frontend`. Activation is a symlink swap, so no request is ever served from a half-written directory, and a rollback is a symlink change rather than a rebuild. + +--- + +## 3. First-time setup + +### 3.1 Generate a CI deploy key + +On your machine, **not** on the server: + +```bash +ssh-keygen -t ed25519 -C "ci@marketplaces" -f ./marketplaces_deploy -N "" +``` + +Two files result. `marketplaces_deploy.pub` goes to the server; `marketplaces_deploy` (private) goes into CI secrets and nowhere else. + +### 3.2 Provision the server + +Copy `scripts/deploy/` to the server and run: + +```bash +sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)" +``` + +This installs nginx + certbot, creates a **key-only** `deploy` user with no password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and grants `deploy` exactly one sudo right: `systemctl reload nginx`. + +Verify before continuing: + +```bash +curl -I http:///health +``` + +Expect `200`. A placeholder page is served until the first real deploy. + +### 3.3 Capture the host key + +```bash +ssh-keyscan -H +``` + +The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or impersonated server fails the deploy instead of being trusted silently. + +### 3.4 Add CI secrets + +| Secret | Value | +|---|---| +| `DEPLOY_HOST` | server IP or hostname | +| `DEPLOY_USER` | `deploy` | +| `DEPLOY_SSH_KEY` | contents of the **private** key file | +| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H ` | + +### 3.5 Deploy + +Push to `main`, or run the workflow manually with a ref. The workflow refuses to swap the symlink unless the uploaded release contains an `index.html`, so a failed upload leaves the previous release serving. + +--- + +## 4. TLS + +No certificate is issued during provisioning, because certbot cannot validate a domain that does not yet point at the server. + +Per domain, once its A record resolves here: + +```bash +sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www +``` + +This writes a server block for that domain (same root — it exists only to give certbot a concrete `server_name`), issues the certificate, enables the HTTP→HTTPS redirect, and enables `certbot.timer` for renewal. + +Verify: + +```bash +curl -I https://shop.example.com/health +sudo certbot certificates +``` + +**On wildcards:** `add-domain.sh` uses HTTP-01, which cannot issue wildcards. If tenants all live under one apex (`*.marketplaces.example.com`), a DNS-01 wildcard is fewer moving parts — but it requires API credentials for the DNS provider and a different certbot plugin. Not set up here; raise it when the tenant count makes per-domain issuance annoying. + +--- + +## 5. Rollback + +```bash +ssh deploy@ +ls -1dt /srv/marketplaces/releases/*/ # newest first +ln -sfnT /srv/marketplaces/releases/ /srv/marketplaces/current.new +mv -Tf /srv/marketplaces/current.new /srv/marketplaces/current +sudo systemctl reload nginx +``` + +Only the last 5 releases are retained. Older ones need a rebuild from the tag. + +--- + +## 6. Operational checks + +```bash +curl -I http:///health # 200 from nginx +readlink -f /srv/marketplaces/current # which commit is live +sudo nginx -t # config valid +systemctl status nginx certbot.timer # both active +sudo tail -f /var/log/nginx/marketplaces.error.log +``` + +--- + +## 7. Known limits + +- **`/api/` 502s until the backend runs.** Expected. nginx proxies to `127.0.0.1:8080`; nothing listens there yet. +- **No staging environment.** `main` goes straight to production. Adding one means a second server plus a `staging` branch trigger. +- **No smoke test beyond HTTP 200.** The verify step confirms nginx serves the shell, not that the app boots. A real check needs the E2E harness from Track Q. +- **Caching.** `index.html` is `no-store`; hashed assets are `immutable` for a year. A deploy therefore takes effect on the next page load, with no cache purge. diff --git a/scripts/deploy/add-domain.sh b/scripts/deploy/add-domain.sh new file mode 100755 index 0000000..839be8e --- /dev/null +++ b/scripts/deploy/add-domain.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Attach one customer domain to this server and issue a TLS certificate. +# Idempotent: re-running for an existing domain renews/repairs rather than duplicates. +# Run as root, AFTER the domain's A/AAAA record already resolves to this server. +# +# bash add-domain.sh shop.example.com --email ops@example.com +# bash add-domain.sh shop.example.com --email ops@example.com --with-www +# +# Why per-domain blocks exist at all: the application is multi-tenant off the +# Host header and needs no per-domain root. Certificates are the exception — +# certbot must match a concrete server_name, which `default_server _` is not. + +set -euo pipefail + +DOMAIN="${1:-}"; shift || true +EMAIL="" +WITH_WWW=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --email) EMAIL="$2"; shift 2 ;; + --with-www) WITH_WWW=1; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; } +[[ -n "$DOMAIN" ]] || { echo "usage: add-domain.sh --email
[--with-www]" >&2; exit 2; } +[[ -n "$EMAIL" ]] || { echo "--email is required (certbot expiry notices)" >&2; exit 2; } + +# Fail loudly rather than let certbot fail obscurely on an unpointed domain. +echo "==> checking DNS for $DOMAIN" +RESOLVED="$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || true)" +if [[ -z "$RESOLVED" ]]; then + echo "ERROR: $DOMAIN does not resolve. Point its A record at this server first." >&2 + exit 1 +fi +echo " resolves to $RESOLVED" + +NAMES="$DOMAIN" +CERT_ARGS=(-d "$DOMAIN") +if [[ $WITH_WWW -eq 1 ]]; then + NAMES="$DOMAIN www.$DOMAIN" + CERT_ARGS+=(-d "www.$DOMAIN") +fi + +CONF="/etc/nginx/sites-available/tenant-$DOMAIN.conf" +echo "==> nginx server block: $CONF" +cat > "$CONF" < certificate" +# --nginx rewrites the block above in place to add listen 443 + ssl directives +# and an HTTP->HTTPS redirect. Re-running is a no-op when the cert is current. +certbot --nginx "${CERT_ARGS[@]}" \ + --non-interactive --agree-tos --email "$EMAIL" \ + --redirect --keep-until-expiring + +nginx -t +systemctl reload nginx + +echo "==> renewal timer" +systemctl enable --now certbot.timer +systemctl status certbot.timer --no-pager | head -3 || true + +echo +echo "done. verify:" +echo " curl -I https://$DOMAIN/health" +echo " certbot certificates | grep -A3 $DOMAIN" diff --git a/scripts/deploy/server-setup.sh b/scripts/deploy/server-setup.sh new file mode 100755 index 0000000..a80eeab --- /dev/null +++ b/scripts/deploy/server-setup.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# One-time server provisioning for the marketplaces frontend. +# Idempotent: safe to re-run. Run as root on the target server. +# +# bash server-setup.sh --pubkey "ssh-ed25519 AAAA... ci@marketplaces" +# +# What it does NOT do: issue TLS certificates (no domain points here yet). +# Run add-domain.sh per domain once DNS resolves. See docs/DEPLOYMENT.md. + +set -euo pipefail + +DEPLOY_USER="deploy" +BASE="/srv/marketplaces" +PUBKEY="" +KEEP_RELEASES=5 + +while [[ $# -gt 0 ]]; do + case "$1" in + --pubkey) PUBKEY="$2"; shift 2 ;; + --user) DEPLOY_USER="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; } +[[ -n "$PUBKEY" ]] || { echo "--pubkey is required (the CI deploy key's PUBLIC half)" >&2; exit 1; } + +echo "==> packages" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq nginx certbot python3-certbot-nginx rsync ufw + +echo "==> deploy user: $DEPLOY_USER" +if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then + # No password is ever set: this account is key-only by construction. + adduser --system --group --shell /bin/bash --home "/home/$DEPLOY_USER" "$DEPLOY_USER" +fi +install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh" +AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys" +touch "$AUTH" +grep -qxF "$PUBKEY" "$AUTH" || echo "$PUBKEY" >> "$AUTH" +chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH" +chmod 600 "$AUTH" + +echo "==> directories" +install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE" "$BASE/releases" +# First deploy creates $BASE/current as a symlink into releases/. +# Seed a placeholder so nginx starts cleanly before anything is deployed. +if [[ ! -e "$BASE/current" ]]; then + install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE/releases/bootstrap/frontend" + echo "marketplaces

Not deployed yet." \ + > "$BASE/releases/bootstrap/frontend/index.html" + chown -R "$DEPLOY_USER:$DEPLOY_USER" "$BASE/releases/bootstrap" + ln -sfn "$BASE/releases/bootstrap" "$BASE/current" + chown -h "$DEPLOY_USER:$DEPLOY_USER" "$BASE/current" +fi + +echo "==> nginx catch-all (multi-tenant: one bundle serves every domain)" +cat > /etc/nginx/sites-available/marketplaces.conf <<'NGINX' +# Multi-tenant by design: the SPA derives its tenant from the Host header, +# so ONE server block serves every customer domain. Do not add a per-tenant +# root here. Per-domain server blocks exist only to hold TLS certificates +# (see add-domain.sh) and proxy to this same root. + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + root /srv/marketplaces/current/frontend; + index index.html; + + access_log /var/log/nginx/marketplaces.access.log; + error_log /var/log/nginx/marketplaces.error.log; + + # Do not let the browser cache the app shell: a deploy must take effect + # on the next reload, not whenever a stale index.html expires. + location = /index.html { + add_header Cache-Control "no-store, must-revalidate" always; + try_files $uri =404; + } + + # Hashed build artifacts are immutable by construction. + location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)$ { + expires 1y; + add_header Cache-Control "public, immutable" always; + try_files $uri =404; + } + + location /health { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # SPA fallback. Must stay last: every unmatched path is a client route. + location / { + try_files $uri $uri/ /index.html; + } + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; +} +NGINX + +ln -sfn /etc/nginx/sites-available/marketplaces.conf /etc/nginx/sites-enabled/marketplaces.conf +rm -f /etc/nginx/sites-enabled/default + +echo "==> firewall" +ufw allow OpenSSH >/dev/null +ufw allow 80/tcp >/dev/null +ufw allow 443/tcp >/dev/null +ufw --force enable >/dev/null + +echo "==> nginx config test" +nginx -t +systemctl enable --now nginx +systemctl reload nginx + +echo "==> sudoers: let the deploy user reload nginx, nothing else" +cat > /etc/sudoers.d/marketplaces-deploy </health -> expect 200" +echo " 2. point a domain's A record here" +echo " 3. bash add-domain.sh -> issues TLS" +echo " 4. add CI secrets, push to main -> first real deploy"