Skip to content
Merged
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
148 changes: 103 additions & 45 deletions .github/workflows/deploy-tencent.yml
Original file line number Diff line number Diff line change
@@ -1,78 +1,136 @@
name: Deploy Tencent
name: Deploy Tencent Docker

permissions:
contents: read

on:
pull_request:
paths:
- apps/web/.dockerignore
- apps/web/Dockerfile
- apps/web/next.config.ts
- apps/web/app/api/health/**
- apps/web/package.json
- apps/web/package-lock.json
- deploy/**
- .github/workflows/deploy-tencent.yml
push:
branches:
- main
- release
workflow_dispatch:

concurrency:
group: deploy-tencent-${{ github.ref }}
group: deploy-tencent-docker-${{ github.ref }}
cancel-in-progress: false

env:
IMAGE_NAME: meteortest-web

jobs:
deploy:
name: Deploy Web
runs-on: [self-hosted, linux, x64, tencent, meteortest]
build:
name: Build Web image
runs-on: ubuntu-latest
environment: tencent
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v6
with:
context: apps/web
file: apps/web/Dockerfile
platforms: linux/amd64
outputs: type=docker,dest=${{ runner.temp }}/meteortest-image.tar
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Compress image artifact
if: github.event_name != 'pull_request'
run: gzip -1 < "${RUNNER_TEMP}/meteortest-image.tar" > deploy/meteortest-image.tar.gz

- name: Upload image artifact to Tencent
if: github.event_name != 'pull_request'
env:
DEPLOY_KEY: ${{ secrets.TENCENT_ARTIFACT_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.TENCENT_ARTIFACT_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ vars.TENCENT_ARTIFACT_HOST }}
DEPLOY_USER: ${{ vars.TENCENT_ARTIFACT_USER }}
run: |
key_file="${RUNNER_TEMP}/artifact-upload-key"
known_hosts_file="${RUNNER_TEMP}/artifact-known-hosts"
printf '%s\n' "${DEPLOY_KEY}" > "${key_file}"
printf '%s\n' "${DEPLOY_KNOWN_HOSTS}" > "${known_hosts_file}"
chmod 0600 "${key_file}" "${known_hosts_file}"
ssh -i "${key_file}" -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="${known_hosts_file}" \
"${DEPLOY_USER}@${DEPLOY_HOST}" "${GITHUB_SHA}" \
< deploy/meteortest-image.tar.gz
rm -f deploy/meteortest-image.tar.gz "${key_file}" "${known_hosts_file}"

- name: Upload deployment manifest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: meteortest-deployment-${{ github.sha }}
path: |
deploy/docker-compose.yml
deploy/deploy-container.sh
retention-days: 7

deploy:
name: Deploy Web container
if: github.event_name != 'pull_request'
needs: build
runs-on: [self-hosted, linux, x64, tencent, meteortest]
environment: tencent
steps:
- name: Download deployment manifest
uses: actions/download-artifact@v4
with:
name: meteortest-deployment-${{ github.sha }}
path: deployment

- name: Resolve target
id: target
shell: bash
run: |
if [ "${GITHUB_REF_NAME}" = "release" ]; then
echo "app_dir=/srv/meteortest-release" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteortest-release" >> "$GITHUB_OUTPUT"
echo "environment=production" >> "$GITHUB_OUTPUT"
echo "port=3200" >> "$GITHUB_OUTPUT"
echo "shadow_port=3210" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteortest-release" >> "$GITHUB_OUTPUT"
echo "public_url=https://meteortest.jcmeteor.com/" >> "$GITHUB_OUTPUT"
else
echo "app_dir=/srv/meteortest" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteortest-web" >> "$GITHUB_OUTPUT"
echo "environment=preview" >> "$GITHUB_OUTPUT"
echo "port=3201" >> "$GITHUB_OUTPUT"
echo "shadow_port=3211" >> "$GITHUB_OUTPUT"
echo "pm2_name=meteortest-web" >> "$GITHUB_OUTPUT"
echo "public_url=https://mt-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT"
fi

- name: Load image artifact
run: gzip -dc "/srv/deploy-inbox/meteortest/${GITHUB_SHA}.tar.gz" | docker load

- name: Deploy
shell: bash
env:
APP_NAME: meteortest
ENVIRONMENT: ${{ steps.target.outputs.environment }}
HOST_PORT: ${{ steps.target.outputs.port }}
IMAGE_URI: meteortest-web:${{ github.sha }}
PM2_NAME: ${{ steps.target.outputs.pm2_name }}
PUBLIC_URL: ${{ steps.target.outputs.public_url }}
RUNTIME_ENV_FILE: /etc/meteortest/meteortest-web.env
SHADOW_PORT: ${{ steps.target.outputs.shadow_port }}
run: |
retry() {
local attempts="$1"
local delay="$2"
shift 2

local attempt=1
until "$@"; do
if [ "$attempt" -ge "$attempts" ]; then
return 1
fi

echo "Command failed. Retrying in ${delay}s (${attempt}/${attempts})..."
sleep "$delay"
attempt=$((attempt + 1))
done
}

mkdir -p '${{ steps.target.outputs.app_dir }}'
rsync -a --delete \
--exclude '.git' \
--exclude 'apps/web/.next' \
--exclude 'apps/web/node_modules' \
./ '${{ steps.target.outputs.app_dir }}/'

cd '${{ steps.target.outputs.app_dir }}/apps/web'
retry 3 15 npm ci
set -a
. /etc/meteortest/meteortest-web.env
set +a
retry 2 15 npm run build
chmod +x deployment/deploy-container.sh
deployment/deploy-container.sh

pm2 delete '${{ steps.target.outputs.pm2_name }}' || true
unset RUNNER_TRACKING_ID
pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start -- --hostname 127.0.0.1 --port '${{ steps.target.outputs.port }}'
pm2 save
retry 12 5 curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null
- name: Remove uploaded image artifact
if: success()
run: rm -f "/srv/deploy-inbox/meteortest/${GITHUB_SHA}.tar.gz"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ main -> mt-pre.jcmeteor.com
release -> meteortest.jcmeteor.com
```

The detailed Docker/TCR target architecture, runner, branch, environment, port mapping, PM2 migration, and rollback flow lives in `docs/tencent-docker-deployment.md`.
The detailed Docker image artifact architecture, runner, branch, environment, port mapping, PM2 migration, and rollback flow lives in `docs/tencent-docker-deployment.md`.

Production publishing is automated by the GitHub Actions `Release Manager` workflow:

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ main -> mt-pre.jcmeteor.com
release -> meteortest.jcmeteor.com
```

Docker/TCR 目标架构、runner、分支、环境变量、端口映射、PM2 迁移和回滚流程详见 `docs/tencent-docker-deployment.zh-CN.md`。
Docker/Actions Artifact 架构、runner、分支、环境变量、端口映射、PM2 迁移和回滚流程详见 `docs/tencent-docker-deployment.zh-CN.md`。

生产发布通过 GitHub Actions 的 `Release Manager` workflow 自动化:

Expand Down
10 changes: 10 additions & 0 deletions apps/web/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.next
node_modules
.env
.env.*
!.env.example
*.log
coverage
playwright-report
test-results
32 changes: 32 additions & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
FROM node:24-bookworm-slim AS dependencies

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM dependencies AS builder

COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:24-bookworm-slim AS runtime

ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000

WORKDIR /app

RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

USER nextjs
EXPOSE 3000

CMD ["node", "server.js"]
8 changes: 8 additions & 0 deletions apps/web/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Process-local health endpoint used by Docker and deployment checks.
*/
import { NextResponse } from 'next/server'

export function GET() {
return NextResponse.json({ service: 'meteortest-web', status: 'ok' })
}
1 change: 1 addition & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
devIndicators: false,
output: "standalone",
};

if (process.env.VERCEL === "1") {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'

const publicPaths = new Set(['/login', '/api/auth/logout', '/favicon.ico'])
const publicPaths = new Set(['/login', '/api/auth/logout', '/api/health', '/favicon.ico'])
const authOptionalPaths = new Set(['/api/agent/status'])

async function getSessionResponse(request: NextRequest) {
Expand Down
104 changes: 104 additions & 0 deletions deploy/deploy-container.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail

required=(APP_NAME ENVIRONMENT IMAGE_URI HOST_PORT SHADOW_PORT RUNTIME_ENV_FILE PM2_NAME PUBLIC_URL)
for name in "${required[@]}"; do
if [ -z "${!name:-}" ]; then
echo "Missing required environment variable: ${name}" >&2
exit 1
fi
done

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
deploy_dir="/srv/containers/${APP_NAME}/${ENVIRONMENT}"
compose_file="${deploy_dir}/docker-compose.yml"
deployment_env="${deploy_dir}/deployment.env"
project_name="${APP_NAME}-${ENVIRONMENT}"

mkdir -p "${deploy_dir}"
install -m 0644 "${script_dir}/docker-compose.yml" "${compose_file}"

write_deployment_env() {
local file="$1"
local image="$2"
local port="$3"
{
printf 'IMAGE_URI=%s\n' "${image}"
printf 'HOST_PORT=%s\n' "${port}"
printf 'RUNTIME_ENV_FILE=%s\n' "${RUNTIME_ENV_FILE}"
} > "${file}"
chmod 0600 "${file}"
}

compose() {
local project="$1"
local env_file="$2"
shift 2
docker compose --project-name "${project}" --env-file "${env_file}" --file "${compose_file}" "$@"
}

wait_for_health() {
local port="$1"
local public_url="${2:-}"
local attempt
for attempt in $(seq 1 24); do
if curl -fsS --max-time 5 "http://127.0.0.1:${port}/api/health" >/dev/null; then
if [ -z "${public_url}" ] || curl -fsS --max-time 10 "${public_url}" >/dev/null; then
return 0
fi
fi
sleep 5
done
return 1
}

current_container="$(compose "${project_name}" "${deployment_env}" ps -q web 2>/dev/null || true)"
previous_image=""
if [ -n "${current_container}" ]; then
previous_image="$(docker inspect --format '{{.Config.Image}}' "${current_container}")"
fi

if ! docker image inspect "${IMAGE_URI}" >/dev/null 2>&1; then
echo "Image is not loaded: ${IMAGE_URI}" >&2
exit 1
fi

if [ -z "${current_container}" ] && pm2 describe "${PM2_NAME}" >/dev/null 2>&1; then
shadow_env="$(mktemp)"
shadow_project="${project_name}-shadow"
write_deployment_env "${shadow_env}" "${IMAGE_URI}" "${SHADOW_PORT}"
trap 'compose "${shadow_project}" "${shadow_env}" down --remove-orphans >/dev/null 2>&1 || true; rm -f "${shadow_env}"' EXIT

compose "${shadow_project}" "${shadow_env}" up -d --wait --wait-timeout 180
wait_for_health "${SHADOW_PORT}"

pm2 stop "${PM2_NAME}"
write_deployment_env "${deployment_env}" "${IMAGE_URI}" "${HOST_PORT}"
if ! compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 \
|| ! wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"; then
compose "${project_name}" "${deployment_env}" down --remove-orphans || true
pm2 restart "${PM2_NAME}" --update-env
wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"
exit 1
fi

compose "${shadow_project}" "${shadow_env}" down --remove-orphans
rm -f "${shadow_env}"
trap - EXIT
pm2 save
else
write_deployment_env "${deployment_env}" "${IMAGE_URI}" "${HOST_PORT}"
if ! compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 \
|| ! wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"; then
if [ -n "${previous_image}" ]; then
echo "Deployment failed; restoring ${previous_image}" >&2
write_deployment_env "${deployment_env}" "${previous_image}" "${HOST_PORT}"
compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180
wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"
fi
exit 1
fi
fi

printf '%s\n' "${IMAGE_URI}" > "${deploy_dir}/current-image"
printf 'Deployed %s to %s (%s)\n' "${IMAGE_URI}" "${project_name}" "${PUBLIC_URL}"
Loading
Loading