Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 9 additions & 131 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
name: CD

# ──────────────────────────────────────────────────────────────────────────────
# Deployment gates stay explicit so staging, smoke checks and production rollout
# remain auditable from a single workflow.
# Deployment gates stay explicit so image publication, production rollout and
# rollback remain auditable from a single workflow.
#
# Triggered only after CI passes on main.
# Workflow:
# build-push → deploy-staging → smoke-staging → [manual approval]
# → deploy-production
# → smoke-production
# build-push → [manual approval] → deploy-production
# → smoke-production
#
# Rollback is a manually dispatched workflow_dispatch job that redeploys the
# previous image tag stored in the GitHub deployment environment.
#
# Required GitHub secrets:
# SSH_HOST_STAGING — IP / hostname of staging server
# SSH_HOST_PRODUCTION — IP / hostname of production server
# SSH_USER — SSH user on both servers
# SSH_USER — SSH user on the production server
# SSH_PRIVATE_KEY — Private key (no passphrase)
# SSH_KNOWN_HOSTS — Output of `ssh-keyscan <host>` for both servers
# SSH_KNOWN_HOSTS — Output of `ssh-keyscan <host>`
# POSTGRES_PASSWORD — injected via Docker env on deploy
# REDIS_PASSWORD — injected via Docker env on deploy
# JWT_SECRET — injected via Docker env on deploy
Expand All @@ -40,9 +38,9 @@ on:
environment:
description: 'Target environment'
required: true
default: staging
default: production
type: choice
options: [staging, production]
options: [production]
image_tag:
description: 'Image tag to deploy (default: latest sha on main)'
required: false
Expand Down Expand Up @@ -126,26 +124,16 @@ jobs:
runs-on: ubuntu-latest
outputs:
production_ready: ${{ steps.check.outputs.production_ready }}
staging_ready: ${{ steps.check.outputs.staging_ready }}
steps:
- name: Check deployment settings
id: check
env:
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
SSH_HOST_PRODUCTION: ${{ secrets.SSH_HOST_PRODUCTION }}
SSH_HOST_STAGING: ${{ secrets.SSH_HOST_STAGING }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
staging_ready=true
for name in DEPLOY_PATH SSH_HOST_STAGING SSH_KNOWN_HOSTS SSH_PRIVATE_KEY SSH_USER; do
if [[ -z "${!name}" ]]; then
echo "::notice::${name} is not configured; staging SSH deploy will be skipped"
staging_ready=false
fi
done

production_ready=true
for name in DEPLOY_PATH SSH_HOST_PRODUCTION SSH_KNOWN_HOSTS SSH_PRIVATE_KEY SSH_USER; do
if [[ -z "${!name}" ]]; then
Expand All @@ -154,126 +142,16 @@ jobs:
fi
done

echo "staging_ready=${staging_ready}" >> "$GITHUB_OUTPUT"
echo "production_ready=${production_ready}" >> "$GITHUB_OUTPUT"

# ── Deploy to staging ─────────────────────────────────────────────────────
deploy-staging:
name: Deploy to staging
needs: [guard, build-push, deploy-config]
runs-on: ubuntu-latest
if: ${{ needs.deploy-config.outputs.staging_ready == 'true' }}
environment:
name: staging
url: https://staging.metrixplatform.com
steps:
- uses: actions/checkout@v4

- name: Validate staging deploy settings
env:
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
SSH_HOST: ${{ secrets.SSH_HOST_STAGING }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
missing=0
for name in DEPLOY_PATH SSH_HOST SSH_KNOWN_HOSTS SSH_PRIVATE_KEY SSH_USER; do
if [[ -z "${!name}" ]]; then
echo "::error::${name} is not configured for staging deployment"
missing=1
fi
done
exit "$missing"

- name: Write SSH key
env:
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

- name: Deploy to staging server
env:
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
IMAGE_PREFIX: ${{ env.IMAGE_PREFIX }}
IMAGE_TAG: ${{ needs.guard.outputs.image_tag }}
SSH_HOST: ${{ secrets.SSH_HOST_STAGING }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
ssh_target="${SSH_USER}@${SSH_HOST}"
ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=yes \
"$ssh_target" \
"DEPLOY_PATH=${DEPLOY_PATH@Q} IMAGE_PREFIX=${IMAGE_PREFIX@Q} IMAGE_TAG=${IMAGE_TAG@Q} bash -s" << 'ENDSSH'
set -euo pipefail
cd "$DEPLOY_PATH/apps/bot"

# Pull new images.
echo "${{ secrets.GITHUB_TOKEN }}" | \
docker login ghcr.io -u ${{ github.actor }} --password-stdin

TAG="${IMAGE_TAG}"

for svc in admin-service analytics-service booking-service bot-gateway \
calendar-service notification-service payment-service \
security-service worker-service; do
docker pull "${IMAGE_PREFIX}/${svc}:${TAG}"
done

# Write current tag for rollback reference.
echo "${TAG}" > .last-deployed-tag

# Rolling restart: update each service image reference and redeploy.
export IMAGE_TAG="${TAG}"
docker compose up -d --no-build \
admin-service analytics-service booking-service bot-gateway \
calendar-service notification-service payment-service \
security-service worker-service

# Wait for all containers to be healthy (max 120 s).
deadline=$(($(date +%s) + 120))
while [[ $(date +%s) -lt $deadline ]]; do
unhealthy=$(docker compose ps --format json \
| jq -r 'select(.Health != "healthy" and .Health != "") | .Name')
[[ -z "$unhealthy" ]] && echo "All services healthy" && break
echo "Waiting for: $unhealthy"
sleep 5
done
ENDSSH

# ── Smoke test staging ────────────────────────────────────────────────────
smoke-staging:
name: Smoke test staging
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- name: Run smoke tests against staging
env:
BOOKING_SERVICE_URL: https://staging.metrixplatform.com/booking
INTEGRATION_TEST: 'true'
run: npm run test:integration -- --test-name-pattern="smoke"

# ── Manual approval gate before production ───────────────────────────────
# GitHub Environments with required reviewers provides the approval UI.
# Set up: Settings → Environments → production → Required reviewers.

# ── Deploy to production ──────────────────────────────────────────────────
deploy-production:
name: Deploy to production
needs: [guard, smoke-staging, deploy-config]
needs: [guard, build-push, deploy-config]
runs-on: ubuntu-latest
if: ${{ needs.deploy-config.outputs.production_ready == 'true' }}
environment:
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Metrix — это система бронирования офисных рес
- architecture/TRACING.md — распределённая трассировка (OpenTelemetry + Jaeger).
- architecture/LOGGING.md — structured JSON logging, Loki/Vector и корреляция с traceId.
- deployment/README.md — как запускать проект.
- deployment/CD.md — CD pipeline: staging → approval → production → rollback.
- deployment/CD.md — CD pipeline: build/push → approval → production → rollback.
- operations/README.md — что делать при инцидентах.
- testing/PRODUCTION_READINESS_TEST_REPORT.md — что уже проверяли.
- testing/CONTRACT_TESTING.md — контрактные тесты между микросервисами.
Expand Down
24 changes: 9 additions & 15 deletions docs/deployment/CD.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Обзор

CD pipeline автоматически доставляет каждый коммит в `main` на production после прохождения CI и smoke-тестов на staging. Ручное действие не требуется, но есть два явных gates.
CD pipeline автоматически собирает Docker images после прохождения CI и
доставляет их на production через GitHub Environment approval gate.

```
CI (typecheck + tests + openapi + audit + contracts)
Expand All @@ -11,12 +12,6 @@ CI (typecheck + tests + openapi + audit + contracts)
build-push → GHCR (sha-<commit> + latest)
deploy-staging (SSH + docker compose pull + rolling restart)
smoke-staging (integration tests против staging URL)
[manual approval — GitHub Environment: production]
Expand Down Expand Up @@ -50,30 +45,29 @@ GHCR/Docker repository path должен быть lowercase, иначе `docker

## Окружения

В GitHub Settings → Environments нужно создать два окружения:
В GitHub Settings → Environments нужно создать production-окружение:

| Окружение | Required reviewers | URL |
|-------------|-------------------|----------------------------------|
| `staging` | нет | https://staging.metrixplatform.com |
| `production`| ≥ 1 reviewer | https://metrixplatform.com |

`production` с required reviewers — это и есть ручной approval gate.

Если SSH secrets или `DEPLOY_PATH` не настроены, pipeline только соберёт и
опубликует GHCR images, а SSH deploy jobs будут skipped с notice в CD run.
Это защищает `main` от красного CD в репозиториях без подключённых серверов.
опубликует GHCR images, а production SSH deploy job будет skipped с notice в
CD run. Это защищает `main` от красного CD в репозиториях без подключённого
production-сервера.

## Необходимые секреты

Добавить в GitHub Settings → Secrets → Actions:

| Секрет | Описание |
|------------------------|----------------------------------------------------|
| `SSH_HOST_STAGING` | IP или hostname staging-сервера |
| `SSH_HOST_PRODUCTION` | IP или hostname production-сервера |
| `SSH_USER` | SSH-пользователь на обоих серверах |
| `SSH_USER` | SSH-пользователь на production-сервере |
| `SSH_PRIVATE_KEY` | Приватный ключ (без passphrase) |
| `SSH_KNOWN_HOSTS` | `ssh-keyscan staging_host production_host` |
| `SSH_KNOWN_HOSTS` | `ssh-keyscan production_host` |
| `POSTGRES_PASSWORD` | Передаётся в docker env при деплое |
| `REDIS_PASSWORD` | Передаётся в docker env при деплое |
| `JWT_SECRET` | Передаётся в docker env при деплое |
Expand Down Expand Up @@ -108,6 +102,6 @@ Rollback происходит автоматически, если smoke-тес
## Добавление нового сервиса

1. Добавить имя сервиса в матрицу `build-push` в `cd.yml`.
2. Добавить имя сервиса в команды `docker compose up` в шагах `deploy-staging` и `deploy-production`.
2. Добавить имя сервиса в команды `docker compose up` в шаге `deploy-production`.
3. Добавить `image: ${IMAGE_PREFIX:-ghcr.io/hqzdev/metrix}/<service-name>:${IMAGE_TAG:-latest}` в `docker-compose.yml`.
4. Добавить `OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318` в environment нового сервиса в `docker-compose.yml`.
Loading