292 lines
15 KiB
YAML
292 lines
15 KiB
YAML
name: Deploy staging
|
|
|
|
run-name: Deploy staging from main
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Validate deployment settings
|
|
env:
|
|
STAGING_HOST: ${{ vars.STAGING_HOST }}
|
|
STAGING_PORT: ${{ vars.STAGING_PORT }}
|
|
STAGING_APP_DIR: ${{ vars.STAGING_APP_DIR }}
|
|
STAGING_COMPOSE_FILE: ${{ vars.STAGING_COMPOSE_FILE }}
|
|
STAGING_USER: ${{ secrets.STAGING_USER }}
|
|
STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
|
STAGING_SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }}
|
|
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
|
ADMIN_CANDIDATE_TENANT_ID: ${{ vars.ADMIN_CANDIDATE_TENANT_ID }}
|
|
INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${{ vars.INITIAL_SUPER_ADMIN_PHONE_NUMBER }}
|
|
ADMIN_WEB_URL: ${{ vars.ADMIN_WEB_URL }}
|
|
BASE_URL: ${{ vars.BASE_URL }}
|
|
SMTP_ENABLED: ${{ vars.SMTP_ENABLED }}
|
|
SMTP_HOST: ${{ vars.SMTP_HOST }}
|
|
SMTP_SENDER: ${{ vars.SMTP_SENDER }}
|
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
|
MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }}
|
|
SSO_ISSUER: ${{ vars.SSO_ISSUER }}
|
|
SSO_CLIENT_ID: ${{ vars.SSO_CLIENT_ID }}
|
|
SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }}
|
|
SECRETARY_ABC_API_KEY: ${{ secrets.SECRETARY_ABC_API_KEY }}
|
|
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
|
|
GITEA_API_TOKEN: ${{ secrets.EXTERNAL_ISSUE_API_TOKEN }}
|
|
GITHUB_API_TOKEN: ${{ secrets.EXTERNAL_GITHUB_API_TOKEN }}
|
|
JIRA_API_EMAIL: ${{ vars.JIRA_API_EMAIL }}
|
|
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
|
JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }}
|
|
JIRA_ISSUE_TYPE: ${{ vars.JIRA_ISSUE_TYPE }}
|
|
NAVER_WORKS_ENABLED: ${{ vars.NAVER_WORKS_ENABLED }}
|
|
NAVER_WORKS_API_BASE_URL: ${{ vars.NAVER_WORKS_API_BASE_URL }}
|
|
NAVER_WORKS_AUTH_URL: ${{ vars.NAVER_WORKS_AUTH_URL }}
|
|
NAVER_WORKS_BOT_ID: ${{ vars.NAVER_WORKS_BOT_ID }}
|
|
NAVER_WORKS_DEFAULT_ROOM_ID: ${{ vars.NAVER_WORKS_DEFAULT_ROOM_ID }}
|
|
NAVER_WORKS_SCOPE: ${{ vars.NAVER_WORKS_SCOPE }}
|
|
NAVER_WORKS_CLIENT_ID: ${{ secrets.NAVER_WORKS_CLIENT_ID }}
|
|
NAVER_WORKS_CLIENT_SECRET: ${{ secrets.NAVER_WORKS_CLIENT_SECRET }}
|
|
NAVER_WORKS_SERVICE_ACCOUNT: ${{ secrets.NAVER_WORKS_SERVICE_ACCOUNT }}
|
|
NAVER_WORKS_PRIVATE_KEY: ${{ secrets.NAVER_WORKS_PRIVATE_KEY }}
|
|
run: |
|
|
set -eu
|
|
|
|
STAGING_HOST="${STAGING_HOST:-172.16.10.175}"
|
|
STAGING_PORT="${STAGING_PORT:-22}"
|
|
STAGING_APP_DIR="${STAGING_APP_DIR:-/home/user/baron_qa}"
|
|
STAGING_COMPOSE_FILE="${STAGING_COMPOSE_FILE:-docker/docker-compose.prod.yml}"
|
|
export STAGING_HOST STAGING_PORT STAGING_APP_DIR STAGING_COMPOSE_FILE
|
|
required_vars="NEXT_PUBLIC_API_BASE_URL ADMIN_WEB_URL BASE_URL SSO_ISSUER SSO_CLIENT_ID ADMIN_CANDIDATE_TENANT_ID INITIAL_SUPER_ADMIN_PHONE_NUMBER NAVER_WORKS_ENABLED NAVER_WORKS_BOT_ID NAVER_WORKS_DEFAULT_ROOM_ID"
|
|
required_secrets="STAGING_USER STAGING_SSH_PRIVATE_KEY STAGING_SSH_KNOWN_HOSTS JWT_SECRET MASTER_API_KEY SSO_CLIENT_SECRET GITEA_API_TOKEN NAVER_WORKS_CLIENT_ID NAVER_WORKS_CLIENT_SECRET NAVER_WORKS_SERVICE_ACCOUNT NAVER_WORKS_PRIVATE_KEY"
|
|
|
|
for name in $required_vars $required_secrets; do
|
|
if [ -z "${!name:-}" ]; then
|
|
echo "Missing Gitea variable or secret: $name" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
test "$STAGING_COMPOSE_FILE" = "docker/docker-compose.prod.yml" || {
|
|
echo "STAGING_COMPOSE_FILE must point to docker/docker-compose.prod.yml" >&2
|
|
exit 1
|
|
}
|
|
|
|
echo "Deployment settings are present."
|
|
|
|
- name: Deploy to staging over SSH
|
|
env:
|
|
STAGING_HOST: ${{ vars.STAGING_HOST }}
|
|
STAGING_PORT: ${{ vars.STAGING_PORT }}
|
|
STAGING_APP_DIR: ${{ vars.STAGING_APP_DIR }}
|
|
STAGING_COMPOSE_FILE: ${{ vars.STAGING_COMPOSE_FILE }}
|
|
STAGING_USER: ${{ secrets.STAGING_USER }}
|
|
STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
|
STAGING_SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }}
|
|
APP_ENV: ${{ vars.APP_ENV }}
|
|
WEB_PORT: ${{ vars.WEB_PORT }}
|
|
API_PORT: ${{ vars.API_PORT }}
|
|
SECRETARY_API_PORT: ${{ vars.SECRETARY_API_PORT }}
|
|
MYSQL_PORT: ${{ vars.MYSQL_PORT }}
|
|
MYSQL_SECRETARY_PORT: ${{ vars.MYSQL_SECRETARY_PORT }}
|
|
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
|
ADMIN_CANDIDATE_TENANT_ID: ${{ vars.ADMIN_CANDIDATE_TENANT_ID }}
|
|
ADMIN_WEB_URL: ${{ vars.ADMIN_WEB_URL }}
|
|
BASE_URL: ${{ vars.BASE_URL }}
|
|
SMTP_ENABLED: ${{ vars.SMTP_ENABLED }}
|
|
SMTP_HOST: ${{ vars.SMTP_HOST }}
|
|
SMTP_PORT: ${{ vars.SMTP_PORT }}
|
|
SMTP_SENDER: ${{ vars.SMTP_SENDER }}
|
|
SMTP_TLS: ${{ vars.SMTP_TLS }}
|
|
SMTP_CIPHER_SPEC: ${{ vars.SMTP_CIPHER_SPEC }}
|
|
SMTP_OPPORTUNISTIC_TLS: ${{ vars.SMTP_OPPORTUNISTIC_TLS }}
|
|
ACCESS_TOKEN_EXPIRED_TIME: ${{ vars.ACCESS_TOKEN_EXPIRED_TIME }}
|
|
REFRESH_TOKEN_EXPIRED_TIME: ${{ vars.REFRESH_TOKEN_EXPIRED_TIME }}
|
|
AUTO_MIGRATION: ${{ vars.AUTO_MIGRATION }}
|
|
OPENSEARCH_USE: ${{ vars.OPENSEARCH_USE }}
|
|
OPENSEARCH_NODE: ${{ vars.OPENSEARCH_NODE }}
|
|
OPENSEARCH_USERNAME: ${{ vars.OPENSEARCH_USERNAME }}
|
|
OPENSEARCH_PASSWORD: ${{ vars.OPENSEARCH_PASSWORD }}
|
|
SSO_ISSUER: ${{ vars.SSO_ISSUER }}
|
|
SSO_CLIENT_ID: ${{ vars.SSO_CLIENT_ID }}
|
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
|
MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }}
|
|
INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${{ vars.INITIAL_SUPER_ADMIN_PHONE_NUMBER }}
|
|
ADMIN_CANDIDATE_EMAILS: ${{ secrets.ADMIN_CANDIDATE_EMAILS }}
|
|
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
|
|
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }}
|
|
SECRETARY_ABC_API_KEY: ${{ secrets.SECRETARY_ABC_API_KEY }}
|
|
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
|
|
GITEA_API_TOKEN: ${{ secrets.EXTERNAL_ISSUE_API_TOKEN }}
|
|
GITHUB_API_TOKEN: ${{ secrets.EXTERNAL_GITHUB_API_TOKEN }}
|
|
JIRA_API_EMAIL: ${{ vars.JIRA_API_EMAIL }}
|
|
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
|
JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }}
|
|
JIRA_ISSUE_TYPE: ${{ vars.JIRA_ISSUE_TYPE }}
|
|
NAVER_WORKS_ENABLED: ${{ vars.NAVER_WORKS_ENABLED }}
|
|
NAVER_WORKS_API_BASE_URL: ${{ vars.NAVER_WORKS_API_BASE_URL }}
|
|
NAVER_WORKS_AUTH_URL: ${{ vars.NAVER_WORKS_AUTH_URL }}
|
|
NAVER_WORKS_BOT_ID: ${{ vars.NAVER_WORKS_BOT_ID }}
|
|
NAVER_WORKS_DEFAULT_ROOM_ID: ${{ vars.NAVER_WORKS_DEFAULT_ROOM_ID }}
|
|
NAVER_WORKS_SCOPE: ${{ vars.NAVER_WORKS_SCOPE }}
|
|
NAVER_WORKS_MAX_RETRIES: ${{ vars.NAVER_WORKS_MAX_RETRIES }}
|
|
NAVER_WORKS_MAX_MESSAGE_LENGTH: ${{ vars.NAVER_WORKS_MAX_MESSAGE_LENGTH }}
|
|
NAVER_WORKS_ACCESS_TOKEN: ${{ secrets.NAVER_WORKS_ACCESS_TOKEN }}
|
|
NAVER_WORKS_CLIENT_ID: ${{ secrets.NAVER_WORKS_CLIENT_ID }}
|
|
NAVER_WORKS_CLIENT_SECRET: ${{ secrets.NAVER_WORKS_CLIENT_SECRET }}
|
|
NAVER_WORKS_SERVICE_ACCOUNT: ${{ secrets.NAVER_WORKS_SERVICE_ACCOUNT }}
|
|
NAVER_WORKS_PRIVATE_KEY: ${{ secrets.NAVER_WORKS_PRIVATE_KEY }}
|
|
run: |
|
|
set -eu
|
|
set -o pipefail
|
|
umask 077
|
|
|
|
STAGING_HOST="${STAGING_HOST:-172.16.10.175}"
|
|
STAGING_PORT="${STAGING_PORT:-22}"
|
|
STAGING_APP_DIR="${STAGING_APP_DIR:-/home/user/baron_qa}"
|
|
STAGING_COMPOSE_FILE="${STAGING_COMPOSE_FILE:-docker/docker-compose.prod.yml}"
|
|
export STAGING_HOST STAGING_PORT STAGING_APP_DIR STAGING_COMPOSE_FILE
|
|
ssh_dir="$RUNNER_TEMP/staging-ssh"
|
|
mkdir -p "$ssh_dir"
|
|
chmod 700 "$ssh_dir"
|
|
|
|
key_file="$ssh_dir/id_ed25519"
|
|
known_hosts_file="$ssh_dir/known_hosts"
|
|
printf '%s\n' "$STAGING_SSH_PRIVATE_KEY" > "$key_file"
|
|
printf '%s\n' "$STAGING_SSH_KNOWN_HOSTS" > "$known_hosts_file"
|
|
chmod 600 "$key_file" "$known_hosts_file"
|
|
trap 'rm -rf "$ssh_dir"' EXIT
|
|
|
|
derived_public_key="$ssh_dir/id_ed25519.pub"
|
|
if ! ssh-keygen -y -f "$key_file" > "$derived_public_key" 2>/dev/null; then
|
|
echo "STAGING_SSH_PRIVATE_KEY is invalid or passphrase-protected." >&2
|
|
exit 1
|
|
fi
|
|
key_fingerprint="$(ssh-keygen -lf "$derived_public_key" | awk '{print $2}')"
|
|
echo "SSH target: $STAGING_USER@$STAGING_HOST:$STAGING_PORT"
|
|
echo "SSH key fingerprint: $key_fingerprint"
|
|
|
|
shell_quote() {
|
|
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\"'\\\"'/g")"
|
|
}
|
|
|
|
remote="$STAGING_USER@$STAGING_HOST"
|
|
remote_app_dir="$(shell_quote "$STAGING_APP_DIR")"
|
|
ssh_args=(
|
|
-i "$key_file"
|
|
-p "$STAGING_PORT"
|
|
-o BatchMode=yes
|
|
-o IdentitiesOnly=yes
|
|
-o StrictHostKeyChecking=yes
|
|
-o UserKnownHostsFile="$known_hosts_file"
|
|
)
|
|
ssh "${ssh_args[@]}" "$remote" "mkdir -p $remote_app_dir"
|
|
ssh "${ssh_args[@]}" "$remote" "rm -f $remote_app_dir/apps/secretary-api/alembic/versions/0002_role_access_workspaces.py $remote_app_dir/apps/secretary-api/alembic/versions/0009_preassigned_admin_candidates.py"
|
|
tar \
|
|
--exclude='.git' \
|
|
--exclude='node_modules' \
|
|
--exclude='.next' \
|
|
--exclude='dist' \
|
|
--exclude='uploads' \
|
|
--exclude='.venv' \
|
|
--exclude='.venv-*' \
|
|
--exclude='.cache' \
|
|
--exclude='.turbo' \
|
|
--exclude='apps/docs/static' \
|
|
-czf - . | ssh "${ssh_args[@]}" "$remote" "tar -xzf - -C $remote_app_dir"
|
|
|
|
payload_file="$RUNNER_TEMP/staging-env.json"
|
|
python3 - <<'PY' > "$payload_file"
|
|
import base64
|
|
import json
|
|
import os
|
|
|
|
keys = """
|
|
APP_ENV WEB_PORT API_PORT SECRETARY_API_PORT MYSQL_PORT MYSQL_SECRETARY_PORT
|
|
NEXT_PUBLIC_API_BASE_URL ADMIN_WEB_URL BASE_URL SMTP_ENABLED SMTP_HOST SMTP_PORT SMTP_SENDER
|
|
SMTP_TLS SMTP_CIPHER_SPEC SMTP_OPPORTUNISTIC_TLS ACCESS_TOKEN_EXPIRED_TIME
|
|
REFRESH_TOKEN_EXPIRED_TIME AUTO_MIGRATION OPENSEARCH_USE OPENSEARCH_NODE
|
|
OPENSEARCH_USERNAME OPENSEARCH_PASSWORD SSO_ISSUER SSO_CLIENT_ID JWT_SECRET
|
|
MASTER_API_KEY INITIAL_SUPER_ADMIN_PHONE_NUMBER ADMIN_CANDIDATE_EMAILS ADMIN_CANDIDATE_TENANT_ID
|
|
SMTP_USERNAME SMTP_PASSWORD SSO_CLIENT_SECRET SECRETARY_ABC_API_KEY GITEA_API_URL GITEA_API_TOKEN GITHUB_API_TOKEN
|
|
JIRA_API_EMAIL JIRA_API_TOKEN JIRA_WEBHOOK_SECRET JIRA_ISSUE_TYPE
|
|
NAVER_WORKS_ENABLED NAVER_WORKS_API_BASE_URL NAVER_WORKS_AUTH_URL NAVER_WORKS_ACCESS_TOKEN
|
|
NAVER_WORKS_BOT_ID NAVER_WORKS_DEFAULT_ROOM_ID NAVER_WORKS_CLIENT_ID NAVER_WORKS_CLIENT_SECRET
|
|
NAVER_WORKS_SERVICE_ACCOUNT NAVER_WORKS_PRIVATE_KEY NAVER_WORKS_SCOPE NAVER_WORKS_MAX_RETRIES
|
|
NAVER_WORKS_MAX_MESSAGE_LENGTH
|
|
""".split()
|
|
|
|
values = {key: os.environ.get(key, "") for key in keys}
|
|
encoded = base64.b64encode(
|
|
json.dumps(values, ensure_ascii=False, separators=(",", ":")).encode()
|
|
).decode()
|
|
print(encoded)
|
|
PY
|
|
|
|
remote_command="set -eu
|
|
APP_DIR=$(shell_quote "$STAGING_APP_DIR")
|
|
COMPOSE_FILE=$(shell_quote "$STAGING_COMPOSE_FILE")
|
|
test -d \"\$APP_DIR\"
|
|
cd \"\$APP_DIR\"
|
|
python3 -c 'import base64,json,os,subprocess,sys,time,urllib.request
|
|
compose=sys.argv[1]
|
|
payload=sys.stdin.buffer.read().strip()
|
|
if not payload:
|
|
raise SystemExit(\"Deployment payload is empty.\")
|
|
values=json.loads(base64.b64decode(payload))
|
|
os.environ.update(values)
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"config\",\"--quiet\"],check=True)
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"up\",\"-d\",\"--build\"],check=True)
|
|
checks=[\"http://127.0.0.1:\"+os.environ.get(\"WEB_PORT\",\"3030\")+\"/api/health\"]
|
|
for url in checks:
|
|
last_error=\"no response\"
|
|
for _ in range(60):
|
|
try:
|
|
with urllib.request.urlopen(url,timeout=3) as response:
|
|
if response.status < 500:
|
|
break
|
|
last_error=\"HTTP \"+str(response.status)
|
|
except Exception as error:
|
|
last_error=repr(error)
|
|
time.sleep(2)
|
|
else:
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=False)
|
|
for service in (\"api\",\"secretary-api\",\"web\",\"mysql\",\"mysql-secretary\"):
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"logs\",\"--tail=100\",service],check=False)
|
|
raise SystemExit(\"Health check failed: \"+url+\" (\"+last_error+\")\")
|
|
secretary_check=[\"docker\",\"compose\",\"-f\",compose,\"exec\",\"-T\",\"secretary-api\",\"python3\",\"-c\",\"import urllib.request; urllib.request.urlopen(\\\"http://127.0.0.1:8010/api/health\\\", timeout=5)\"]
|
|
secretary_error=\"no response\"
|
|
for _ in range(60):
|
|
try:
|
|
subprocess.run(secretary_check,check=True)
|
|
break
|
|
except subprocess.CalledProcessError as error:
|
|
secretary_error=repr(error)
|
|
time.sleep(2)
|
|
else:
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=False)
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"logs\",\"--tail=200\",\"secretary-api\"],check=False)
|
|
raise SystemExit(\"Secretary API health check failed: \"+secretary_error)
|
|
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=True)
|
|
print(\"Staging deployment and health checks passed.\")' \"\$COMPOSE_FILE\""
|
|
|
|
cat "$payload_file" | ssh \
|
|
-i "$key_file" \
|
|
-p "$STAGING_PORT" \
|
|
-o BatchMode=yes \
|
|
-o IdentitiesOnly=yes \
|
|
-o StrictHostKeyChecking=yes \
|
|
-o UserKnownHostsFile="$known_hosts_file" \
|
|
"$remote" "$remote_command"
|
|
|
|
rm -f "$payload_file"
|