建立开发机镜像构建与标准发布流程 (#1)

This commit is contained in:
WuYongJie 2026-07-21 16:17:24 +08:00
parent 7dbcacc3cb
commit 57fcd366f3
10 changed files with 666 additions and 27 deletions

View File

@ -1,4 +1,8 @@
.env
*.pem
*.tar
artifacts
runtime
.git
.gitignore
.idea
@ -11,6 +15,8 @@ storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
tests
test-data
docs
scripts
npm-debug.log
yarn-error.log

4
.gitignore vendored
View File

@ -102,3 +102,7 @@ modules/*
!modules/PaypalStandard/**
.laravelstatsrc
.DS_Store
*.pem
*.tar
/artifacts/
/runtime/

View File

@ -11,7 +11,7 @@
## 2. 分支与提交
- `main` 是稳定主线,禁止直接推送、禁止强推、禁止直接修改。
- `main` 是稳定主线,默认禁止直接推送、禁止强推、禁止直接修改。项目负责人 `WuYongJie`,以及得到其明确授权的 Codex,可以直接合并 PR 或直接推送 `main`;任何身份仍禁止强推。
- 每项任务都必须从最新 `main` 新建分支;不得长期共用或复用旧任务分支。
- 分支命名:新功能用 `feature/用户名-任务名`,修复用 `fix/用户名-问题名`,紧急修复用 `hotfix/用户名-问题名`。
- 示例:`feature/dodo-login`、`fix/wangshu-amount-rounding`。
@ -27,7 +27,7 @@
4. 要求 AI 先说明改动计划、涉及文件、风险和验证步骤;确认后再修改。
5. 修改后查看 diff,并要求 AI 用普通语言解释“改了什么、为什么、怎样测试”。
6. 在本地或测试环境完成最小有效验证,记录实际结果。
7. 推送任务分支并发起 PR;不得自行绕过 PR 合并到 `main`。
7. 推送任务分支并发起 PR;新人不得自行绕过 PR 合并到 `main`。项目负责人和其明确授权的 Codex 按上面的例外执行。
推荐向 AI 提问的模板:
@ -42,9 +42,9 @@
## 4. PR 与合并
- 所有任务分支必须通过 PR 合并到 `main`。
- 新人和普通协作者的任务分支必须通过 PR 合并到 `main`;项目负责人和其明确授权的 Codex 可以作为例外直接合并或推送。
- PR 作者必须填写:做了什么、Codex 改了哪些文件、怎样测试、是否涉及金额/权限/数据库/配置。
- PR 至少由另一位成员检查后才能批准;作者不得把“AI 已完成”当作测试结果。
- 新人提交的 PR 至少由另一位成员检查后才能批准;作者不得把“AI 已完成”当作测试结果。项目负责人和其明确授权的 Codex 不受这条审核人数限制,但仍必须完成实际验证并如实记录结果。
- 涉及金额计算、权限、导入、数据库迁移、账户或系统配置时,必须在 PR 中明确标注,并由负责人最终确认。
- 使用仓库默认的 Squash merge(压缩合并)。合并由负责人或指定合并人执行。
- PR 合并后分支自动删除;不要把已合并分支继续用于新任务。
@ -70,3 +70,45 @@
- 数据库迁移:新人不得自行在共享环境执行;先在 PR 说明影响、验证方式和回滚思路。
- 未完成验证时,PR 必须明确写“未验证”及原因,不能写“应该没问题”。
## 8. 固定构建与部署规则
### 8.1 职责边界
- 项目负责人只负责产品方向、验收标准和高风险授权,不负责日常构建、上传、部署命令和服务器维护操作。
- 新人负责描述任务、检查结果和发起 PR;Codex 负责按本节流程完成环境检查、构建、部署、验证和收口,不得把一串命令丢给项目负责人执行。
- 正式发布只允许使用已合并的 `main`。非 `main` 只能用于明确授权的测试发布。
- 生产数据库迁移、数据修复、删除、覆盖配置等高风险动作,仍需项目负责人明确授权。
### 8.2 服务器红线
- 当前服务器只有 2GB 内存,只负责运行容器、健康检查和镜像切换。
- 严禁在服务器执行 `docker build`、`docker compose build`、`npm install`、`npm run production`、`composer install` 或其他源码构建命令。
- `compose.jiangling.yml` 不得出现 `build:`;服务器只能运行开发机已经构建并上传的 `caiwu_akaunting:<commit>` 镜像。
- 服务器保留当前镜像和上一个可回滚镜像,其他本项目旧镜像在发布成功后清理。
- 服务器发布必须使用互斥锁;同一时间只允许一个人切换版本,禁止并发覆盖。
### 8.3 开发机固定流程
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,不得手工拼接构建、打包、上传和启动命令。
- 镜像标签使用 Git 提交 SHA;工作区有未提交的受控文件时,脚本必须拒绝发布。
- 使用项目专属 BuildKit 构建器 `caiwu-builder`。禁止日常使用 `--no-cache`、`--pull` 或删除全部 Docker 数据。
- Dockerfile 必须先复制锁文件再安装依赖:只有 `composer.lock`、`package-lock.json` 或基础镜像定义变化时,才允许对应依赖层失效。
- 基础镜像、Composer 下载缓存、npm 下载缓存和未变化的构建层应直接复用,不得每次全量下载。
### 8.4 磁盘和缓存上限
- Docker Desktop 为本项目构建至少分配 8GB 内存,虚拟磁盘上限建议设为 60GB;宿主机至少保留 15GB 可用空间。
- 发布脚本使用独立缓存并将 BuildKit 缓存控制在 8GB 内,只清理 7 天前的本项目缓存。
- 开发机和服务器默认各保留 2 个本项目镜像;上传用临时 `image.tar` 无论成功失败都必须删除。
- 禁止脚本自动执行全局 `docker system prune -a`、删除其他项目镜像、删除卷或重置 Docker Desktop。
- Docker 虚拟磁盘文件不会因为普通清理立即缩小;日常目标是控制已用空间不再增长,不得让新人自行删除或重建虚拟磁盘。
### 8.5 发布安全与验收
- SSH 私钥、`runtime/.env`、镜像临时包、数据库密码不得进入 Git 或 Docker 构建上下文。
- 首次服务器初始化由脚本生成数据库密码和 `APP_KEY`,不在终端、聊天或文档中输出。
- 数据库迁移默认关闭;检测到新增迁移时发布必须停止,获得负责人授权后才可显式开启。
- 新镜像必须通过真实 HTTP 页面健康检查后才算发布成功;失败时自动恢复上一个镜像,并保留日志用于排查。
- 发布完成必须报告:分支、提交 SHA、镜像标签、服务器容器状态、真实页面结果、是否执行迁移、清理结果。
详细 SOP 见 `docs/开发与部署SOP.md`。

View File

@ -1,22 +1,45 @@
# 服务器只运行本机已加载的镜像,禁止在服务器执行 docker compose build。
services:
app:
build:
context: .
dockerfile: deploy/Dockerfile
image: jiangling_akaunting:local
container_name: jiangling_app
db:
image: mariadb:11.4
pull_policy: missing
container_name: caiwu_db
restart: unless-stopped
network_mode: host
env_file:
- ./runtime/.env
volumes:
- caiwu_database:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
app:
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
pull_policy: never
container_name: caiwu_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
ports:
- "80:18088"
volumes:
- ./runtime/.env:/var/www/html/.env
- jiangling_storage:/var/www/html/storage
- caiwu_storage:/var/www/html/storage
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/auth/login"]
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/"]
interval: 15s
timeout: 5s
retries: 10
start_period: 45s
volumes:
jiangling_storage:
name: jiangling_storage
caiwu_database:
name: caiwu_database
caiwu_storage:
name: caiwu_storage

View File

@ -1,4 +1,6 @@
FROM php:8.3-apache-bookworm
# syntax=docker/dockerfile:1.7
FROM php:8.3-apache-bookworm AS php-base
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@ -28,14 +30,57 @@ RUN apt-get update \
&& a2enmod expires headers rewrite \
&& rm -rf /var/lib/apt/lists/*
FROM php-base AS vendor
COPY --from=composer:2.8 /usr/bin/composer /usr/local/bin/composer
WORKDIR /build
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache composer install \
--prefer-dist \
--no-dev \
--no-interaction \
--no-progress \
--no-scripts \
--optimize-autoloader
FROM node:20-bookworm-slim AS assets
WORKDIR /build
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
COPY . .
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run production
FROM php-base AS runtime
ARG VCS_REF=unknown
ARG BUILD_DATE=unknown
LABEL com.caiwu.project="CaiWuXiTong" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.created="$BUILD_DATE"
WORKDIR /var/www/html
COPY . /var/www/html
COPY --from=vendor /build/vendor /var/www/html/vendor
COPY --from=assets /build/public/js /var/www/html/public/js
COPY --from=assets /build/public/css /var/www/html/public/css
COPY --from=assets /build/public/mix-manifest.json /var/www/html/public/mix-manifest.json
COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini
COPY deploy/entrypoint.sh /usr/local/bin/jiangling-entrypoint
COPY deploy/php.ini /usr/local/etc/php/conf.d/caiwu.ini
COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint
RUN chmod +x /usr/local/bin/jiangling-entrypoint \
RUN chmod +x /usr/local/bin/caiwu-entrypoint \
&& mkdir -p \
storage/app/public \
storage/app/temp \
@ -47,6 +92,5 @@ RUN chmod +x /usr/local/bin/jiangling-entrypoint \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
ENTRYPOINT ["jiangling-entrypoint"]
ENTRYPOINT ["caiwu-entrypoint"]
CMD ["apache2-foreground"]

View File

@ -0,0 +1,94 @@
#!/bin/sh
set -eu
IMAGE_ARCHIVE="${1:?IMAGE_ARCHIVE is required}"
IMAGE_TAG="${2:?IMAGE_TAG is required}"
REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
cleanup() {
rm -f "$IMAGE_ARCHIVE"
}
trap cleanup EXIT INT TERM
if [ "$(id -u)" -ne 0 ]; then
echo "activate-release.sh must run as root" >&2
exit 1
fi
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
exit 1
fi
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Another CaiWuXiTong deployment is already running" >&2
exit 1
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
docker load --input "$IMAGE_ARCHIVE"
rm -f "$IMAGE_ARCHIVE"
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
exit 1
fi
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d --remove-orphans
healthy=0
attempt=1
while [ "$attempt" -le 30 ]; do
if curl -fsS -o /dev/null http://127.0.0.1/; then
healthy=1
break
fi
sleep 5
attempt=$((attempt + 1))
done
if [ "$healthy" -ne 1 ]; then
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
case "$PREVIOUS_IMAGE" in
"$IMAGE_NAME":*)
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
RUN_MIGRATIONS=0
export IMAGE_TAG RUN_MIGRATIONS
docker compose -f compose.jiangling.yml up -d app || true
;;
esac
echo "Release failed health check; previous image was restored when available" >&2
exit 1
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
| awk '!seen[$0]++' \
| tail -n "+$((KEEP_IMAGES + 1))" \
| while IFS= read -r old_image; do
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
done
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
docker compose -f compose.jiangling.yml ps
echo "DEPLOYED_SHA=$DEPLOY_SHA"
# Keep the variable for rollback diagnostics without printing any secret.
: "$PREVIOUS_SHA"

View File

@ -0,0 +1,99 @@
#!/bin/sh
set -eu
APP_URL="${1:?APP_URL is required}"
REMOTE_DIR="${2:-/opt/caiwuxitong}"
if [ "$(id -u)" -ne 0 ]; then
echo "bootstrap-server.sh must run as root" >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl openssl
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
. /etc/os-release
ARCH="$(dpkg --print-architecture)"
CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}"
{
printf '%s\n' \
'Types: deb' \
'URIs: https://download.docker.com/linux/ubuntu' \
"Suites: $CODENAME" \
'Components: stable' \
"Architectures: $ARCH" \
'Signed-By: /etc/apt/keyrings/docker.asc'
} > /etc/apt/sources.list.d/docker.sources
apt-get update
if command -v docker >/dev/null 2>&1; then
apt-get install -y --no-install-recommends docker-buildx-plugin docker-compose-plugin
else
apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
systemctl enable --now docker
if ! swapon --show=NAME --noheadings | grep -q .; then
if [ ! -f /swapfile ]; then
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile >/dev/null
fi
swapon /swapfile
if ! grep -q '^/swapfile ' /etc/fstab; then
printf '%s\n' '/swapfile none swap sw 0 0' >> /etc/fstab
fi
fi
install -d -m 700 "$REMOTE_DIR/runtime"
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
DB_PASSWORD="$(openssl rand -hex 24)"
DB_ROOT_PASSWORD="$(openssl rand -hex 24)"
APP_KEY="$(openssl rand -base64 32 | tr -d '\n')"
umask 077
{
printf '%s\n' \
'APP_NAME=CaiWuXiTong' \
'APP_ENV=production' \
"APP_KEY=base64:$APP_KEY" \
'APP_DEBUG=false' \
'APP_INSTALLED=false' \
"APP_URL=$APP_URL" \
'APP_LOCALE=zh-CN' \
'APP_FALLBACK_LOCALE=en-GB' \
'APP_TIMEZONE=Asia/Shanghai' \
'LOG_CHANNEL=stack' \
'DB_CONNECTION=mysql' \
'DB_HOST=db' \
'DB_PORT=3306' \
'DB_DATABASE=caiwu' \
'DB_USERNAME=caiwu' \
"DB_PASSWORD=$DB_PASSWORD" \
'DB_PREFIX=ak_' \
'CACHE_DRIVER=file' \
'SESSION_DRIVER=file' \
'QUEUE_CONNECTION=sync' \
'MARIADB_DATABASE=caiwu' \
'MARIADB_USER=caiwu' \
"MARIADB_PASSWORD=$DB_PASSWORD" \
"MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \
'MARIADB_AUTO_UPGRADE=1'
} > "$REMOTE_DIR/runtime/.env"
chmod 600 "$REMOTE_DIR/runtime/.env"
fi
docker --version
docker compose version
echo "Server runtime is ready: $REMOTE_DIR"

View File

@ -14,16 +14,23 @@ mkdir -p \
chown -R www-data:www-data storage bootstrap/cache
if [ -f .env ]; then
chown root:www-data .env
chmod 640 .env
chown www-data:www-data .env
chmod 660 .env
fi
if [ "${1:-}" = "apache2-foreground" ]; then
sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf
php artisan optimize:clear
php artisan migrate --force
php artisan config:cache
php artisan view:cache
php artisan package:discover --ansi
if grep -Eq '^APP_INSTALLED=(true|1)$' .env; then
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
php artisan migrate --force
fi
php artisan config:cache
php artisan view:cache
fi
fi
exec "$@"

135
docs/开发与部署SOP.md Normal file
View File

@ -0,0 +1,135 @@
# CaiWuXiTong 开发与部署 SOP
## 1. 这套流程解决什么问题
本项目由 6 位新人使用 Codex 协作开发。项目负责人只确认方向、验收结果和高风险动作,不负责日常命令操作。
固定链路如下:
```text
任务分支开发 -> 本机验证 -> PR -> 合并 main
-> 开发机复用缓存构建镜像
-> 临时导出并上传镜像
-> 2GB 服务器只加载和切换镜像
-> 真实页面健康检查
-> 保留当前与上一个镜像,清理其余本项目产物
```
服务器永远不构建源码。
## 2. 每台开发机只做一次的设置
1. 安装并启动 Docker Desktop,使用 WSL2 后端。
2. 在 Docker Desktop 的 `Settings -> Resources -> Advanced` 中:
- 构建可用内存至少 8GB;
- Docker 虚拟磁盘上限建议 60GB;
- Windows 磁盘至少保留 15GB 可用空间。
3. SSH 私钥放到个人目录,例如 `C:\Users\你的用户名\.ssh\caiwu.pem`,不要放进 Git。
4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。
说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。
## 3. 为什么后续不会反复全量下载
项目使用专属构建器 `caiwu-builder`,缓存与其他项目分开:
- PHP 扩展层只在 Dockerfile 的基础环境变化时重建;
- Composer 只在 `composer.lock` 变化时重新解析依赖,并复用下载缓存;
- npm 只在 `package-lock.json` 变化时重新安装依赖,并复用下载缓存;
- 业务代码变化只重新执行后面的代码复制和资源构建;
- 日常命令不带 `--pull` 和 `--no-cache`。
首次构建必然较慢;后续构建应大量显示 `CACHED`。
## 4. 首次初始化服务器
首次发布由 Codex 或当班开发者在仓库根目录执行:
```powershell
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
-BootstrapServer
```
初始化脚本会:
- 安装并启动 Docker 与 Compose;
- 为 2GB 服务器增加 2GB swap(交换空间),仅用于运行时缓冲;
- 创建 `/opt/caiwuxitong/runtime/.env`;
- 在服务器本地生成数据库密码和 Laravel `APP_KEY`,不会打印出来;
- 创建持久化数据库卷和上传文件卷。
首次打开 `http://118.196.84.79` 后,由负责人或指定人员在网页安装向导中设置管理员账号和密码。
## 5. 日常正式发布
前提:PR 已审核并合并,当前为干净的 `main`。
```powershell
git switch main
git pull --ff-only
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem"
```
脚本自动完成:
1. 检查分支和未提交改动;
2. 使用提交 SHA 构建镜像;
3. 复用项目专属缓存;
4. 导出临时镜像包并上传;
5. 服务器加载镜像并启动;
6. 检查真实 HTTP 页面;
7. 失败时恢复上一个镜像;
8. 使用服务器互斥锁阻止两个人同时切换版本;
9. 删除本地和服务器临时镜像包;
10. 两端仅保留当前和上一个本项目镜像;
11. 清理 7 天前的本项目构建缓存,并将总量压到最多 8GB。
## 6. 数据库迁移
默认禁止自动迁移。脚本发现上次部署后新增了 `database/migrations` 文件时会停止。
只有负责人明确批准、PR 已写清影响与回滚思路后,才允许:
```powershell
.\scripts\deploy-from-workstation.ps1 `
-Server 118.196.84.79 `
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
-AllowMigrations
```
镜像回滚不能自动撤销已经执行的数据库结构变更,因此迁移授权必须单独处理。
## 7. 测试分支发布
正式服务器默认只允许 `main`。确需临时测试时,必须得到明确授权,并添加:
```powershell
-AllowNonMain
```
测试完成后仍要回到 `main` 再正式发布,不能把测试分支当长期生产版本。
## 8. 禁止事项
- 禁止登录服务器运行 `docker build` 或 `docker compose build`;
- 禁止在服务器运行 Composer、npm 或前端打包;
- 禁止把 `.pem`、`.env`、密码、数据库文件或 `image.tar` 提交到 Git;
- 禁止为了省空间删除 Docker 卷;
- 禁止全局清空 Docker,因为同一台电脑可能还有其他项目;
- 禁止跳过健康检查后口头说“部署完成”。
## 9. 发布完成时应报告
- 发布分支和完整 Git SHA;
- 镜像标签;
- 服务器 `caiwu_app`、`caiwu_db` 状态;
- `http://118.196.84.79` 的实际返回结果;
- 是否执行数据库迁移;
- 本地临时包是否删除;
- 本地/服务器保留了几个本项目镜像;
- BuildKit 缓存当前占用。

View File

@ -0,0 +1,185 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Server,
[Parameter(Mandatory = $true)]
[string]$KeyPath,
[string]$SshUser = 'root',
[string]$RemotePath = '/opt/caiwuxitong',
[string]$AppUrl,
[string]$BuilderName = 'caiwu-builder',
[int]$KeepImages = 2,
[switch]$BootstrapServer,
[switch]$AllowNonMain,
[switch]$AllowMigrations
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$keyFullPath = (Resolve-Path $KeyPath).Path
$sshTarget = "${SshUser}@${Server}"
$imageName = 'caiwu_akaunting'
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N'))
$localArchive = Join-Path $tempRoot 'image.tar'
$remoteArchive = $null
$bootstrapRemote = $null
function Invoke-Native {
param(
[Parameter(Mandatory = $true)]
[scriptblock]$Command,
[Parameter(Mandatory = $true)]
[string]$FailureMessage
)
& $Command
if ($LASTEXITCODE -ne 0) {
throw $FailureMessage
}
}
function Remove-OldProjectImages {
param([string]$CurrentImage)
$projectImages = @(
docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' |
Where-Object { $_ -and $_ -ne '<none>:<none>' } |
Select-Object -Unique
)
$keep = @($CurrentImage)
foreach ($candidate in $projectImages) {
if ($keep.Count -ge $KeepImages) { break }
if ($candidate -notin $keep) { $keep += $candidate }
}
foreach ($candidate in $projectImages) {
if ($candidate -notin $keep) {
docker image rm $candidate 2>$null | Out-Null
}
}
}
Push-Location $repoRoot
try {
$branch = (git branch --show-current).Trim()
if (-not $AllowNonMain -and $branch -ne 'main') {
throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。"
}
$trackedChanges = git status --porcelain --untracked-files=no
if ($trackedChanges) {
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
}
$deploySha = (git rev-parse HEAD).Trim()
$imageTag = $deploySha.Substring(0, 12)
$fullImage = "${imageName}:$imageTag"
$remoteArchive = "$RemotePath/image-$imageTag.tar"
$buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
if (-not $AppUrl) {
$AppUrl = "http://$Server"
}
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
docker buildx inspect $BuilderName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Invoke-Native {
docker buildx create --name $BuilderName --driver docker-container --use | Out-Null
} '创建项目专属 BuildKit 构建器失败。'
}
Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。'
Invoke-Native {
docker buildx build `
--builder $BuilderName `
--load `
--tag $fullImage `
--build-arg "VCS_REF=$deploySha" `
--build-arg "BUILD_DATE=$buildDate" `
--label 'com.caiwu.project=CaiWuXiTong' `
--file deploy/Dockerfile `
.
} '本机镜像构建失败。服务器未发生任何变更。'
$previousSha = (& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true").Trim()
if ($LASTEXITCODE -ne 0) {
throw '无法连接服务器。'
}
$previousShaExists = $false
if ($previousSha) {
git cat-file -e "${previousSha}^{commit}" 2>$null
$previousShaExists = $LASTEXITCODE -eq 0
}
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch,或在负责人授权后使用 -AllowMigrations。"
}
if ($previousSha -and $previousShaExists) {
$migrationChanges = git diff --name-only "$previousSha..$deploySha" -- database/migrations
if ($migrationChanges -and -not $AllowMigrations) {
throw "检测到数据库迁移:$($migrationChanges -join ', ')。需负责人确认后添加 -AllowMigrations。"
}
}
New-Item -ItemType Directory -Path $tempRoot | Out-Null
if ($BootstrapServer) {
$bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh"
Invoke-Native {
scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote"
} '上传服务器初始化脚本失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'"
} '服务器初始化失败。'
}
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
Invoke-Native { docker save --output $localArchive $fullImage } '导出镜像失败。'
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes compose.jiangling.yml deploy/activate-release.sh "${sshTarget}:$RemotePath/" } '上传运行配置失败。'
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/activate-release.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
} '服务器激活或健康检查失败。'
Remove-OldProjectImages -CurrentImage $fullImage
Write-Host "发布完成:$fullImage -> $AppUrl"
}
finally {
Pop-Location
if (Test-Path -LiteralPath $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
if ($remoteArchive) {
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
}
if ($bootstrapRemote) {
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null
}
$pruneHelp = docker buildx prune --help 2>$null | Out-String
docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null
if ($pruneHelp -match '--max-used-space') {
docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null
}
elseif ($pruneHelp -match '--keep-storage') {
docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null
}
docker system df
}