为什么要走 CI 自动部署

PipeMonitor 的 Flutter Web 端发布目标是 https://pipe-monitor.varka.cn/scada/,对应 VPS 上的 /var/www/pipe-monitor.varka.cn/scada/。最早是手动流程:本机 flutter build web,再用 scprsyncbuild/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.htmlmain.dart.jsflutter_bootstrap.jsassets/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

几个关键设计:

  • 密钥缺失提前 failsecrets.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 FILE
  • ssh-keygen -y -f:从私钥推导公钥并丢弃,相当于做一次格式校验。私钥损坏或格式不对时这里会先失败,比等到 rsync 时再报错好排查
  • ssh-keyscan -H:把 VPS 的 host key 预写入 known_hosts,避免首次连接时交互式 yes/no 拷问。-H 是 hash 后存储,防止 known_hosts 泄露时暴露主机名

REMOTE_HOSTREMOTE_PORTREMOTE_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 SecretsVPS_SSH_KEY(私钥全文),只在 Setup SSH 步骤里通过 ${{ secrets.VPS_SSH_KEY }} 引用,不会出现在日志里
  • workflow envREMOTE_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 引用要避免 echoprintf '%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@v2cache: 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 导致的误判

后续阅读