From f9bf1b3eb39f63515ddc42b395514be6373c2448 Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Mon, 19 Oct 2026 20:59:38 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E9=83=A8=E7=BD=B2=E5=88=B0=E8=85=BE?= =?UTF-8?q?=E8=AE=AF=E4=BA=91=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=B9=B6=E5=90=AF?= =?UTF-8?q?=E7=94=A8=20Gitea=20Actions=20=E8=87=AA=E5=8A=A8=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml 增加 deploy job:verify 全绿后 rsync 到 /opt/kanban/dist 并做线上自检 - runner 与站点同机(host 模式),部署直接写本地目录,不依赖 SSH 密钥与 Secrets - 站点目录是容器 bind mount,必须原地同步;manual deploy.sh 同步改为 rsync 语义 - 新增 deploy/:容器版 nginx 配置、runner 配置与 systemd 单元、服务器初始化脚本 - README 更新实际部署架构(1Panel + Nginx Proxy Manager 反代)与 runner 说明 --- .gitea/workflows/ci.yml | 55 +++++++++++++++++-- README.md | 97 ++++++++++++++++++++++++++++----- deploy/deploy.sh | 71 ++++++++++++++++++++++++ deploy/gitea-runner.config.yaml | 21 +++++++ deploy/gitea-runner.service | 20 +++++++ deploy/nginx.conf | 74 +++++++++++++++++++++++++ deploy/nginx.docker.conf | 53 ++++++++++++++++++ deploy/server-init.sh | 73 +++++++++++++++++++++++++ 8 files changed, 446 insertions(+), 18 deletions(-) create mode 100644 deploy/deploy.sh create mode 100644 deploy/gitea-runner.config.yaml create mode 100644 deploy/gitea-runner.service create mode 100644 deploy/nginx.conf create mode 100644 deploy/nginx.docker.conf create mode 100644 deploy/server-init.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6ab8730..34d1dc8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +# 站点目录(宿主路径)。Nginx 容器挂载的就是这个目录,rsync 原地同步后立即生效。 +env: + DEPLOY_PATH: /opt/kanban/dist + jobs: verify: runs-on: ubuntu-latest @@ -17,11 +21,10 @@ jobs: - name: 检出代码 uses: actions/checkout@v4 - - name: 准备 Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm + # 这是自托管 runner(host 模式),宿主机已装 Node 22, + # 不再用 setup-node 下载一份到 tool cache,省时间和磁盘。 + - name: 环境确认 + run: node -v && npm -v - name: 安装依赖 run: npm ci @@ -37,3 +40,45 @@ jobs: - name: 生产构建 run: npm run build + + # 只在 master / main 的 push、且 verify 全绿之后部署。 + # runner 与站点同机,直接写本地目录,不需要 SSH 密钥和任何 Secrets。 + deploy: + name: 部署到服务器 + needs: verify + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装依赖 + run: npm ci + + - name: 生产构建 + run: npm run build + + # 站点目录是容器的 bind mount,不能整目录 mv(挂载跟 inode,容器会看不到新内容), + # 必须原地同步:rsync 覆盖变化文件并删掉上一版残留的 hash 资源。 + - name: 同步到站点目录 + run: | + set -euo pipefail + rsync -a --delete dist/ "$DEPLOY_PATH"/ + echo "已同步到 $DEPLOY_PATH" + + - name: 线上自检 + run: | + set -euo pipefail + for i in 1 2 3 4 5; do + curl -fsS -o /dev/null http://127.0.0.1:8081/ && break + echo "第 $i 次探测失败,1 秒后重试" + sleep 1 + done + curl -fsS -o /dev/null http://127.0.0.1:8081/ + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/today) + echo "根路径 200,/today $code" + [ "$code" = "200" ] || { echo "SPA 回落异常:/today 返回 $code"; exit 1; } + curl -fsS http://127.0.0.1:8081/sw.js >/dev/null + echo "自检通过" diff --git a/README.md b/README.md index 8194af0..26a9323 100644 --- a/README.md +++ b/README.md @@ -169,19 +169,61 @@ npm run build # 将 dist/ 上传或指向静态服务器 ``` -Nginx 示例: +### 部署到云服务器(当前实际架构) + +服务器由 1Panel 管理,80/443 已被 **Nginx Proxy Manager** 占用,所以站点不装宿主机 Nginx, +而是跑一个 `nginx:alpine` 容器,由 NPM 反代: -```nginx -server { - listen 80; - root /var/www/kanban/dist; - index index.html; - location / { - # 必须配置:/today 是真实路径,刷新时文件不存在,需要回落到 index.html - try_files $uri $uri/ /index.html; - } -} ``` +浏览器 → NPM(80/443, 证书) → kanban 容器(:80) → /opt/kanban/dist + ↑ + CI runner 构建后 rsync 到这里(原地同步) +``` + +| 文件 | 用途 | +| -------------------------------------- | ---------------------------------------------------------------- | +| `deploy/nginx.docker.conf` | 容器内站点配置:SPA 回落、`sw.js` 不缓存、`/assets` 长缓存、gzip | +| `deploy/gitea-runner.config.yaml` | runner 配置:host 模式、`ubuntu-latest` 标签 | +| `deploy/gitea-runner.service` | runner 的 systemd 单元 | +| `deploy/deploy.sh` | 不走 CI 时的手动发布脚本 | +| `deploy/nginx.conf` + `server-init.sh` | 备选路径:80 端口空闲、直接用宿主机 Nginx 的机器 | + +**容器启动命令**(已在服务器上运行,重建时照抄): + +```bash +docker run -d --name kanban --restart unless-stopped \ + --network 1panel-network \ + -p 127.0.0.1:8081:80 \ + -v /opt/kanban/dist:/usr/share/nginx/html:ro \ + -v /opt/kanban/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ + nginx:alpine +``` + +**反向代理**:NPM 后台 → Hosts → Proxy Hosts → Add + +| 字段 | 值 | +| ---------------- | -------------------- | +| Domain Names | `kanban.shoulin.net` | +| Scheme | `http` | +| Forward Hostname | `172.17.0.1` | +| Forward Port | `8081` | + +其余站点都走 `172.17.0.1:端口`,这里保持一致(用容器名 `kanban:80` 实测也可通,但容器重建后 IP 可能变)。 +SSL 页签挂证书并开启 Force SSL,域名解析到服务器 IP 即可。 + +**手动发布**(CI 不可用时): + +```bash +SERVER=root@132.232.203.88 WEB_ROOT=/opt/kanban/dist ./deploy/deploy.sh +``` + +> 注意:`/opt/kanban/dist` 是容器挂载目录,**不能整目录 `mv`**——bind mount 跟的是 inode, +> 换目录后容器仍指向旧内容。`deploy.sh` 与 CI 都用 rsync 原地同步,就是为了避开这个坑。 + +> **HTTPS 不是可选项**:Service Worker 只在 HTTPS 或 localhost 下注册成功。用 IP 走 HTTP 访问时, +> 应用能打开,但「安装到桌面 / 离线可用 / 版本更新提示」全部失效。证书在 NPM 里配置。 + +### 其它静态托管 Netlify / Vercel 等平台,在 `dist/` 同级放一个 `_redirects` 或配置即可: @@ -223,9 +265,38 @@ scripts/ # 图标生成等辅助脚本 .gitea/workflows/ # CI(lint / typecheck / test / build) ``` -## 持续集成 +## 持续集成与自动部署 -仓库内置 `.gitea/workflows/ci.yml`:推送与合并请求会依次执行 `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build`。 +仓库内置 `.gitea/workflows/ci.yml`,分两个 job: + +| Job | 触发条件 | 做什么 | +| -------- | ------------------------------------------ | --------------------------------------------------- | +| `verify` | push 与合并请求 | `npm ci` → `lint` → `typecheck` → `test` → `build` | +| `deploy` | push 到 `master` / `main` 且 `verify` 通过 | 重新构建 → `rsync` 到 `/opt/kanban/dist` → 线上自检 | + +合并请求只跑校验、不部署。部署用 `rsync -a --delete` 原地同步,失败时旧文件仍在,不会把站点弄空; +同步完会自检根路径、`/today` 回落与 `sw.js`,任一异常就让 job 变红。 + +### Runner(自托管,已部署) + +服务器 `132.232.203.88` 上的 `tencent-sh-1`,host 模式,systemd 常驻: + +```bash +systemctl status gitea-runner +journalctl -u gitea-runner -n 50 --no-pager +``` + +配置见 `deploy/gitea-runner.config.yaml` 与 `deploy/gitea-runner.service`,几个要点: + +- 标签 `ubuntu-latest:host`,任务直接在宿主机执行,不再套一层容器 +- `DEFAULT_ACTIONS_URL=https://gitea.com` —— 这台机器访问 github.com 不通,action 必须换源 +- 宿主机 Node 22 在 `/opt/node22`,软链到 `/usr/local/bin`;npm 源指向 `registry.npmmirror.com` +- runner 与站点同机,部署直接写本地目录,**不需要配置任何 Secrets** + +### 为什么不用 SSH 部署 + +runner 与站点同一台机器,绕一圈 SSH 只是多一个故障点、多一份私钥要管。 +真要跨机部署,用 `deploy/deploy.sh` 的 `SERVER=... WEB_ROOT=...` 形式即可。 E2E 需要真实浏览器,默认不放进 CI(要求 runner 预装 Chrome);请在本地或用带浏览器的 runner 执行 `npm run test:e2e`。 diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..3ad2734 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# 一键发布到云服务器:本地构建 → 上传 → 原地同步 +# +# 用法: +# SERVER=root@132.232.203.88 ./deploy/deploy.sh +# SERVER=root@1.2.3.4 WEB_ROOT=/var/www/kanban SSH_PORT=2222 ./deploy/deploy.sh +# +# 环境变量: +# SERVER 远端 SSH 目标(必填),如 root@1.2.3.4 +# WEB_ROOT 远端站点目录,默认 /opt/kanban/dist(容器挂载的就是这里) +# SSH_PORT SSH 端口,默认 22 +# SKIP_BUILD 设为 1 时跳过本地构建,直接发布现有 dist/ +# +# 注意:站点目录是容器的 bind mount,必须「原地同步」。 +# 整目录 mv 之后容器仍指向旧 inode,看不到新文件 —— 所以这里只用 rsync / tar 解包原地覆盖。 + +set -euo pipefail + +# 无论从哪里调用,都切到仓库根目录 +cd "$(dirname "$0")/.." + +SERVER="${SERVER:-}" +WEB_ROOT="${WEB_ROOT:-/opt/kanban/dist}" +SSH_PORT="${SSH_PORT:-22}" +SKIP_BUILD="${SKIP_BUILD:-0}" + +if [ -z "$SERVER" ]; then + echo "缺少 SERVER。示例:SERVER=root@1.2.3.4 $0" >&2 + exit 1 +fi + +if [ "$SKIP_BUILD" != "1" ]; then + echo "==> 本地构建" + npm run build +fi + +if [ ! -f dist/index.html ]; then + echo "dist/index.html 不存在,构建可能失败了" >&2 + exit 1 +fi + +echo "==> 远端准备目录 $WEB_ROOT" +ssh -p "$SSH_PORT" "$SERVER" "mkdir -p '$WEB_ROOT'" + +if command -v rsync >/dev/null 2>&1; then + echo "==> rsync 原地增量同步" + rsync -az --delete -e "ssh -p $SSH_PORT" dist/ "$SERVER:$WEB_ROOT/" +else + # Windows / Git Bash 一般没有 rsync:上传到远端临时目录,再在远端原地同步 + echo "==> tar 上传后远端原地同步(本地未检测到 rsync)" + STAMP=$(date +%Y%m%d%H%M%S) + STAGE="/tmp/kanban-release-$STAMP" + + ssh -p "$SSH_PORT" "$SERVER" "rm -rf '$STAGE' && mkdir -p '$STAGE'" + tar -czf - -C dist . | ssh -p "$SSH_PORT" "$SERVER" "tar -xzf - -C '$STAGE'" + + ssh -p "$SSH_PORT" "$SERVER" " + set -e + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete '$STAGE/' '$WEB_ROOT/' + else + # 没有 rsync 时退化为「清空 + 解包」,会有毫秒级空窗 + find '$WEB_ROOT' -mindepth 1 -delete + cp -a '$STAGE/.' '$WEB_ROOT/' + fi + rm -rf '$STAGE' + " +fi + +echo "==> 发布完成:$SERVER:$WEB_ROOT" +echo " 验证:curl -I http://<域名或 IP>/today # 期望 200,而不是 404" diff --git a/deploy/gitea-runner.config.yaml b/deploy/gitea-runner.config.yaml new file mode 100644 index 0000000..acbec12 --- /dev/null +++ b/deploy/gitea-runner.config.yaml @@ -0,0 +1,21 @@ +# Gitea Runner 配置(服务端 /opt/act_runner/config.yaml) +# +# 宿主模式(host):任务直接在本机执行,不额外起容器。 +# 本机已装 Node 22,所以 ubuntu-latest 标签映射到 host 而不是 docker 镜像。 +# action 源通过 systemd 的 DEFAULT_ACTIONS_URL 指向 gitea.com(本机访问 github.com 不通)。 + +log: + level: info + +runner: + file: .runner + capacity: 1 + timeout: 3h + labels: + - 'ubuntu-latest:host' + fetch_timeout: 5s + fetch_interval: 2s + +# 单机部署不需要 runner 自带缓存服务,关掉避免额外端口 +cache: + enabled: false diff --git a/deploy/gitea-runner.service b/deploy/gitea-runner.service new file mode 100644 index 0000000..314d3a3 --- /dev/null +++ b/deploy/gitea-runner.service @@ -0,0 +1,20 @@ +[Unit] +Description=Gitea Actions Runner (tencent-sh-1) +Documentation=https://docs.gitea.com/usage/actions/act-runner +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/act_runner +Environment=HOME=/root +# 本机访问 github.com 不通,action 改从 gitea.com 拉取 +Environment=DEFAULT_ACTIONS_URL=https://gitea.com +ExecStart=/usr/local/bin/gitea-runner daemon +Restart=always +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..1cb20c1 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,74 @@ +# 看板 · 个人任务管理 —— Nginx 站点配置 +# +# 用法(Ubuntu / Debian): +# sudo cp deploy/nginx.conf /etc/nginx/conf.d/kanban.conf +# sudo vim /etc/nginx/conf.d/kanban.conf # 改 server_name +# sudo nginx -t && sudo systemctl reload nginx +# +# CentOS / RHEL 把文件放到 /etc/nginx/conf.d/kanban.conf 同样可用。 + +server { + listen 80; + listen [::]:80; + server_name kanban.example.com; # ← 改成你的域名或服务器 IP + + # deploy.sh 默认就发布到这个目录 + root /var/www/kanban; + index index.html; + + charset utf-8; + + access_log /var/log/nginx/kanban.access.log; + error_log /var/log/nginx/kanban.error.log; + + # ---------- 缓存策略(PWA 能否正常更新,全看这三条) ---------- + + # 构建产物文件名带 hash,可以长期强缓存 + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Service Worker 绝对不能缓存,否则发布后客户端永远停在旧版本 + location = /sw.js { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # index.html 引用着带 hash 的资源名,同样不能缓存 + location = /index.html { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /manifest.webmanifest { + default_type application/manifest+json; + expires off; + add_header Cache-Control "no-cache"; + } + + # ---------- 前端路由 ---------- + # /today 是真实路径,刷新时磁盘上没有对应文件,必须回落到 index.html。 + # 少了这条,直接访问或刷新 /today 会 404。 + location / { + try_files $uri $uri/ /index.html; + } + + # ---------- 传输压缩 ---------- + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + application/javascript + application/json + application/manifest+json + image/svg+xml; + + # ---------- 配好 HTTPS 之后 ---------- + # 1. 把上面的 listen 改成 443 ssl,并补上证书配置(或用 certbot 自动改) + # 2. 取消下面这行注释,让 80 端口跳转到 443 + # return 301 https://$host$request_uri; +} diff --git a/deploy/nginx.docker.conf b/deploy/nginx.docker.conf new file mode 100644 index 0000000..6bbb29f --- /dev/null +++ b/deploy/nginx.docker.conf @@ -0,0 +1,53 @@ +# 看板 · 容器内站点配置 +# +# 由 kanban 容器挂载到 /etc/nginx/conf.d/default.conf。 +# 站点根目录是容器内的 /usr/share/nginx/html,对应宿主机 /opt/kanban/dist。 +# 宿主机不需要装 Nginx —— 80/443 由已有的 Nginx Proxy Manager 接管,反代到本容器。 + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + charset utf-8; + + # 构建产物带 hash,长期强缓存(只加一条 Cache-Control,避免与 expires 叠加) + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Service Worker 与入口 HTML 不能缓存,否则发布后客户端停在旧版本 + location = /sw.js { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /index.html { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /manifest.webmanifest { + default_type application/manifest+json; + expires off; + add_header Cache-Control "no-cache"; + } + + # 前端路由:/today 刷新时磁盘上没有该文件,必须回落 + location / { + try_files $uri $uri/ /index.html; + } + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + application/javascript + application/json + application/manifest+json + image/svg+xml; +} diff --git a/deploy/server-init.sh b/deploy/server-init.sh new file mode 100644 index 0000000..953dbe5 --- /dev/null +++ b/deploy/server-init.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# 目标服务器初始化(只需执行一次) +# +# 安装 Nginx、建站点目录、写入 deploy/nginx.conf 对应的站点配置并 reload。 +# 不会动 Nginx 里已有的其它站点。 +# +# 用法(本机执行): +# scp deploy/server-init.sh deploy/nginx.conf root@<服务器IP>:/tmp/kanban-deploy/ +# ssh root@<服务器IP> 'bash /tmp/kanban-deploy/server-init.sh kanban.example.com' +# +# 参数: +# $1 域名或 IP,写进 server_name;省略则沿用 nginx.conf 里的值 +# 环境变量: +# WEB_ROOT 站点目录,默认 /var/www/kanban(要和 CI 里的 DEPLOY_PATH 一致) + +set -euo pipefail + +DOMAIN="${1:-}" +WEB_ROOT="${WEB_ROOT:-/var/www/kanban}" +CONF_DEST=/etc/nginx/conf.d/kanban.conf +CONF_SRC="$(cd "$(dirname "$0")" && pwd)/nginx.conf" + +if [ "$(id -u)" -ne 0 ]; then + echo "请用 root 执行(sudo bash $0 ${DOMAIN:-})" >&2 + exit 1 +fi + +if [ ! -f "$CONF_SRC" ]; then + echo "找不到 $CONF_SRC —— server-init.sh 和 nginx.conf 需要放在同一目录" >&2 + exit 1 +fi + +echo "==> 安装 Nginx" +if command -v nginx >/dev/null 2>&1; then + echo " 已安装:$(nginx -v 2>&1)" +elif command -v apt-get >/dev/null 2>&1; then + apt-get update -qq + apt-get install -y nginx +elif command -v dnf >/dev/null 2>&1; then + dnf install -y nginx +else + yum install -y nginx +fi + +echo "==> 创建站点目录 $WEB_ROOT" +mkdir -p "$WEB_ROOT" + +echo "==> 写入站点配置 $CONF_DEST" +if [ -n "$DOMAIN" ]; then + # 只替换 server_name 那一行,其余配置保持与仓库一致 + sed -E "s/^([[:space:]]*)server_name[^;]*;/\1server_name ${DOMAIN};/" "$CONF_SRC" > "$CONF_DEST" +else + cp "$CONF_SRC" "$CONF_DEST" +fi + +nginx -t +systemctl enable nginx >/dev/null 2>&1 || true +systemctl reload nginx 2>/dev/null || systemctl restart nginx + +if systemctl is-active --quiet firewalld 2>/dev/null; then + echo "==> firewalld 放行 80/443" + firewall-cmd --permanent --add-service=http >/dev/null + firewall-cmd --permanent --add-service=https >/dev/null + firewall-cmd --reload >/dev/null +fi + +echo +echo "完成。站点根目录:$WEB_ROOT" +echo "server_name:${DOMAIN:-沿用 nginx.conf 默认值}" +echo +echo "还需要做两件事:" +echo " 1. 腾讯云控制台 → 防火墙/安全组,放行 80(上 HTTPS 后再放行 443)" +echo " 2. 把 CI 部署用的公钥追加到 ~/.ssh/authorized_keys"