为什么要走 CI 自动部署
PipeMonitor 的 Flutter Web 端发布目标是 https://pipe-monitor.varka.cn/scada/,对应 VPS 上的 /var/www/pipe-monitor.varka.cn/scada/。最早是手动流程:本机 flutter build web,再用 scp 或 rsync 把 build/web/ 推到服务器。手动发布有几个明显问题:
- 本机 Flutter SDK 版本漂移,构建产物不一致
- 忘记带
--base-href /scada/,页面资源 404 - 推送前没清理远端旧文件,被删除的资源残留成死文件
- SSH 私钥散落在多台开发机,难以审计
把整套流程搬到 GitHub Actions 上后,构建环境固定、发布步骤版本化、密钥集中在 GitHub Secrets 里,推送 main 分支即发布。这篇拆解 .github/workflows/deploy-pipe-monitor-web.yml 的关键设计。
paths 触发过滤:只在相关变更时跑
PipeMonitor 仓库里同时有 Flutter 客户端、Node.js 后端、Nginx 配置、STM32 固件等内容。如果任何提交都触发 Web 部署,既浪费 Actions 配额,也可能在后端改动时误发网页。
on:
push:
branches: [main]
paths:
- 'flutter/**'
- '.github/workflows/deploy-pipe-monitor-web.yml'
workflow_dispatch:
关键点:
paths只在flutter/目录或本 workflow 文件本身变更时触发,改后端或 Nginx 不跑workflow_dispatch保留手动触发入口,方便强制重发或调试时绕开 paths 过滤- 注意
paths用的是相对仓库根的 glob,flutter/**匹配子目录所有文件,但不匹配根目录的flutter/文件本身,必要时可以再加'flutter/*'
concurrency 防并发
短时间连推几个 commit 时,多个 workflow 实例可能同时跑构建和 rsync,远端目录会被并发写入踩踏。用 concurrency 把同名 group 串起来:
concurrency:
group: deploy-pipe-monitor-web
cancel-in-progress: false
这里有意把 cancel-in-progress 设成 false:
- 部署是不可重入的操作,前一个 rsync 跑到一半被取消,远端文件可能半残
- 串行排队能保证每次发布的完整性,代价是后一个 commit 要多等一两分钟
- 如果是构建任务(不涉及远端写入),可以设
cancel-in-progress: true节省配额;部署任务不建议
Flutter setup 与 pub-cache 缓存
用 subosito/flutter-action@v2 安装 Flutter SDK,并开启内置缓存:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
channel: stable固定用稳定通道,避免 beta/canary 上 API 漂移导致构建失败cache: true会缓存~/.pub-cache和 Flutter SDK 本身,二次构建能省下几分钟- 这里没有钉死
flutter-version,靠 stable 通道的滚动版本;如果需要严格可复现,可以改成flutter-version: '3.x.x'
构建前先拉依赖:
- name: Install dependencies
run: flutter pub get
workflow 在 defaults.run.working-directory: flutter 下执行,所以 flutter pub get 默认在 flutter/ 目录跑,不需要每次 cd。
子路径构建:—base-href /scada/
Flutter Web 默认假设站点根是 /,但 PipeMonitor 的网页部署在 /scada/ 子路径下,与 REST API、WebSocket 共用同一域名。构建时必须把 base href 写死:
- name: Build web
run: flutter build web --release --base-href /scada/
几个细节:
--base-href /scada/会让 Flutter 把<base href="$FLUTTER_BASE_HREF">替换成/scada/,所有资源路径自动加/scada/前缀- 路径必须以
/结尾,否则相对路径解析会错位(/scada和/scada/行为完全不同) --release启用 tree-shaking 和 dart2js/dart2wasm 优化,产物体积比 debug 小一个数量级- 构建产物在
flutter/build/web/下,包含index.html、main.dart.js、flutter_bootstrap.js、assets/、canvaskit/等
SSH 密钥注入:ed25519 + known_hosts
部署到 VPS 用 SSH 私钥认证。私钥存在 GitHub Secrets 里,跑的时候注入到 runner 的 ~/.ssh/:
- name: Setup SSH
run: |
if [ -z "${{ secrets.VPS_SSH_KEY }}" ]; then
echo "::error::Missing GitHub secret VPS_SSH_KEY in the PipeMonitor repository."
exit 1
fi
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.VPS_SSH_KEY }}" | tr -d '\r' > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 > /dev/null
ssh-keyscan -p ${REMOTE_PORT} -H ${REMOTE_HOST} >> ~/.ssh/known_hosts
几个关键设计:
- 密钥缺失提前 fail:
secrets.VPS_SSH_KEY为空时直接exit 1,并用::error::工作流命令在 Actions UI 上高亮报错,否则后面 rsync 会报一堆Permission denied不好定位 tr -d '\r':GitHub Secrets 在 Windows 编辑器里可能混入 CRLF,私钥文件带\r会被 OpenSSH 拒绝,统一去掉chmod 600:OpenSSH 强制私钥文件权限必须是 600 或 400,否则报UNPROTECTED PRIVATE KEY FILEssh-keygen -y -f:从私钥推导公钥并丢弃,相当于做一次格式校验。私钥损坏或格式不对时这里会先失败,比等到 rsync 时再报错好排查ssh-keyscan -H:把 VPS 的 host key 预写入known_hosts,避免首次连接时交互式 yes/no 拷问。-H是 hash 后存储,防止known_hosts泄露时暴露主机名
REMOTE_HOST、REMOTE_PORT、REMOTE_USER 用 workflow 级 env 暴露:
env:
REMOTE_HOST: 39.106.127.204
REMOTE_PORT: 50022
REMOTE_USER: mydei
SSH 端口用 50022 而非默认 22,是 VPS 上一层的 sshd 配置,避开公网上针对 22 端口的扫描噪音。
远程目录存在性与可写性校验
rsync 不会自动创建目标目录的父级,如果 /var/www/pipe-monitor.varka.cn 不存在或当前用户不可写,rsync 会报一堆错。在 rsync 之前显式校验,提前 fail 并给出修复命令:
- name: Validate remote target
run: |
ssh -i ~/.ssh/id_ed25519 -p ${REMOTE_PORT} \
${REMOTE_USER}@${REMOTE_HOST} \
"TARGET='/var/www/pipe-monitor.varka.cn/scada'; \
PARENT='/var/www/pipe-monitor.varka.cn'; \
if [ ! -d \"\$PARENT\" ]; then \
echo '::error::Missing remote directory /var/www/pipe-monitor.varka.cn. Create it first on the server.'; \
exit 1; \
fi; \
if [ ! -w \"\$PARENT\" ]; then \
echo '::error::Remote directory /var/www/pipe-monitor.varka.cn is not writable by the deploy user. Run: sudo mkdir -p /var/www/pipe-monitor.varka.cn/scada && sudo chown -R mydei:mydei /var/www/pipe-monitor.varka.cn'; \
ls -ld \"\$PARENT\"; \
exit 1; \
fi; \
mkdir -p \"\$TARGET\""
这段的设计意图:
- 父目录存在性:
/var/www/pipe-monitor.varka.cn不存在通常是新服务器或 Nginx 配置没就绪,直接报错让运维先建目录 - 父目录可写性:目录存在但属主不是
mydei时,rsync 同样会失败。[ ! -w ]提前检查,并把修复命令直接写在错误信息里,运维复制就能跑 ls -ld输出当前权限:失败时附带实际属主和权限,方便判断是 chown 没做还是 SELinux/AppArmor 拦了mkdir -p "$TARGET":父目录正常时才创建scada/子目录,幂等操作,已存在不报错::error::工作流命令:错误信息会出现在 Actions 日志高亮区,而不是埋在 ssh stdout 里
这一步看似冗余,但能在新服务器迁移或权限被误改后第一时间给出可执行的修复路径,比让 rsync 自己报 failed: No such file or directory 友好得多。
rsync —delete —no-owner 部署
核心发布步骤用 rsync 增量同步:
- name: Deploy app bundle
run: |
rsync -avz --delete --no-owner --no-group --no-perms \
-e "ssh -i ~/.ssh/id_ed25519 -p ${REMOTE_PORT}" \
build/web/ \
${REMOTE_USER}@${REMOTE_HOST}:/var/www/pipe-monitor.varka.cn/scada/
参数逐个说明:
-a:归档模式,递归 + 保留符号链接 + 保留时间戳-v:详细输出,便于 Actions 日志审计哪些文件被更新-z:传输时压缩,对main.dart.js这种文本类资源压缩比很高--delete:删除远端多余文件。Flutter 每次构建文件名带 hash,旧 hash 的 JS 不删就会堆积成几十个无用文件--no-owner --no-group --no-perms:不试图保留源端的属主、属组、权限。远端目录属主是mydei,rsync 以mydei登录写入,文件自然就是mydei:mydei,不需要也无法 chown-e "ssh -i ... -p ...":指定 SSH 私钥和端口,与前面 Setup SSH 步骤一致- 源路径末尾的
/:build/web/表示同步目录内容到远端,不是把web目录本身放过去。漏掉/会在远端多一层scada/web/
为什么不用 scp -r:scp 不支持 --delete,无法清理旧文件;不增量,每次全量传输几 MB 的 main.dart.js。rsync 是这类静态资源部署的标准工具。
环境变量与 secrets 管理
整套 workflow 用了两类敏感信息:
- GitHub Secrets:
VPS_SSH_KEY(私钥全文),只在 Setup SSH 步骤里通过${{ secrets.VPS_SSH_KEY }}引用,不会出现在日志里 - workflow env:
REMOTE_HOST/REMOTE_PORT/REMOTE_USER是 VPS 连接参数,没当作 secret 是因为它们本身不敏感(IP 和 SSH 端口公网扫描就能拿到),放 env 里方便调试和复用
几个值得注意的点:
- secrets 不能在
if条件里直接判空:if: ${{ secrets.VPS_SSH_KEY == '' }}永远为 false,因为 GitHub Actions 不会展开 secrets 到条件表达式。所以 Setup SSH 里用 shell 的[ -z ]判断,更可靠 - secrets 引用要避免 echo:
printf '%s\n' "${{ secrets.VPS_SSH_KEY }}"直接写入文件,不经过echo,避免在进程列表里暴露(虽然 runner 是独占的,但养成习惯) - 公网 IP 写在 env 而非 secret:如果哪天换 VPS,改一个地方就行;写死在多个 step 里维护成本高
- deploy 用户用普通账号:
mydei不是 root,rsync 只能写到/var/www/pipe-monitor.varka.cn/下属主是mydei的目录。即使私钥泄露,攻击面也只限于静态文件目录,碰不到系统配置
完整 workflow 结构
把所有片段拼起来,整体结构是线性的,没有 matrix、没有 needs 拆 job:
name: Deploy pipe-monitor web
on:
push:
branches: [main]
paths:
- 'flutter/**'
- '.github/workflows/deploy-pipe-monitor-web.yml'
workflow_dispatch:
concurrency:
group: deploy-pipe-monitor-web
cancel-in-progress: false
env:
REMOTE_HOST: 39.106.127.204
REMOTE_PORT: 50022
REMOTE_USER: mydei
jobs:
build-and-deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: flutter
steps:
# Checkout / Setup Flutter / pub get / build web
# Setup SSH / Validate remote target / Deploy app bundle
之所以不拆 build 和 deploy 两个 job:构建产物在 build/web/,跨 job 传递要么用 artifacts 上传下载、要么用 docker image,对这种单次几 MB 的静态资源都是过度设计。一个 job 跑完最简单,actions/checkout 拉下来的 workspace 直接就是构建上下文。
小结
paths过滤把发布限定在flutter/**和 workflow 文件本身变更,避免无关提交浪费配额concurrency+cancel-in-progress: false让部署串行排队,前一次 rsync 不会被后一次打断subosito/flutter-action@v2的cache: true缓存 pub-cache 和 SDK,二次构建显著加速flutter build web --release --base-href /scada/是子路径部署的核心,路径必须以/结尾- SSH 注入用
tr -d '\r'去 CRLF、chmod 600修权限、ssh-keygen -y校验私钥、ssh-keyscan预填 known_hosts - 远程目录校验在 rsync 之前 fail,错误信息里直接给出
sudo chown修复命令,运维复制即用 rsync --delete --no-owner --no-group --no-perms增量同步并清理旧 hash 文件,build/web/末尾的/不能漏- secrets 用 shell
[ -z ]判空而非if条件,避免 GitHub Actions 不展开 secrets 导致的误判