CI/CD:自动构建与发布流水线

手动发布的典型流程是:本地 git pullpnpm buildscp 上传、ssh 重启、再刷新页面看一眼。这个过程本身没错,问题在它不可重复——漏跑测试、传错目录、忘了 reload,都会在某次深夜发布里同时发生。CI/CD 的价值不是「炫技」,而是把这条命令序列固化成谁执行都一样的脚本,并且在前一步失败时自动停下。

要解决的问题

  • 最小可用的自动化流水线该包含哪几个阶段?
  • GitHub Actions 怎么写:触发条件、缓存、测试、产物?
  • 怎么把产物安全地送到服务器(密钥与 known_hosts 怎么管)?
  • GitLab CI 的等价写法是什么,测试失败怎么阻断发布?

流水线的五个阶段

阶段做什么失败后怎么办
检出checkout 指定 commit,确保产物可追溯直接失败,通知
依赖安装用 lockfile 精确安装,并挂缓存加速报告 lockfile 冲突
检查与测试lint、类型检查、单元测试阻断发布,不允许「先发再修」
构建产出 dist/ 或镜像,命名带 commit 短哈希失败即终止
部署增量同步 + 原子切换 + reload + 健康检查自动切回上一版本

GitHub Actions 完整示例

# .github/workflows/deploy.yml
name: build-and-deploy
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm                       # 自动缓存包管理器目录
      - run: corepack enable && pnpm install --frozen-lockfile
      - run: pnpm lint && pnpm test         # 失败则整个流水线停止
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}      # 产物名带 commit,可追溯可回滚
          path: dist/
          retention-days: 30
  deploy:
    needs: build                            # 依赖 build,测试没过就不会部署
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist
      - name: 准备 SSH
        run: |
          mkdir -p ~/.ssh
          printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
      - name: 增量同步 + 原子切换
        run: |
          TARGET=/srv/www/site-${{ github.sha }}
          rsync -avz --delete -e "ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes" \
            dist/ "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:$TARGET/"
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
            "ln -sfn $TARGET /srv/www/site-current && sudo nginx -t && sudo systemctl reload nginx"

两个容易出事的地方:known_hosts 的内容要用 Secret 存(在 runner 上临时执行 ssh-keyscan 属于「首次信任即接受」,可被中间人冒充),私钥只放 Secrets 并使用只授权到目标目录的部署专用密钥。

GitLab CI 的等价写法

# .gitlab-ci.yml
stages: [test, build, deploy]

test:
  stage: test
  image: node:22
  script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
    - pnpm lint && pnpm test
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'      # 用 rules 替代已过时的 only

deploy:
  stage: deploy
  image: alpine:3.20
  needs: [test]                              # 测试不过不部署
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  script:
    - apk add --no-cache rsync openssh-client
    - mkdir -p ~/.ssh
    - printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
    - printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - rsync -avz --delete -e ssh dist/ "$SSH_USER@$SSH_HOST:/srv/www/site-$CI_COMMIT_SHORT_SHA/"
    - ssh "$SSH_USER@$SSH_HOST" "ln -sfn /srv/www/site-$CI_COMMIT_SHORT_SHA /srv/www/site-current"

两者结构完全一致:阶段(stages)+ 规则(rules,旧写法是 only)+ 脚本(script)+ 依赖(needs),把 GitHub Actions 搬过来只是换关键字;构建产物在 GitLab 里用 artifacts 声明(paths: [dist/]expire_in: 30 days),作用等价于 upload-artifact

部署用户的权限最小化

# 1) 建专用部署用户,不用 root,也不给 sudo 全权
sudo useradd -m -s /bin/bash deploy
sudo mkdir -p /srv/www && sudo chown deploy:www-data /srv/www

# 2) 只授权一条 sudo 命令:reload nginx
echo 'deploy ALL=(root) NOPASSWD: /bin/systemctl reload nginx' | sudo tee /etc/sudoers.d/deploy-reload
sudo chmod 440 /etc/sudoers.d/deploy-reload

# 3) 部署密钥单独生成,只加进 deploy 用户的 authorized_keys
ssh-keygen -t ed25519 -C 'ci-deploy' -f ~/.ssh/ci_deploy -N ''
ssh-copy-id -i ~/.ssh/ci_deploy.pub deploy@your-server

条件允许时再进一步:限制该密钥只能从 CI 出口 IP 登录(安全组或 sshd_configMatch Address),或只允许执行固定命令(command="...")。

落地顺序

顺序动作完成标志
1本地手工完整发布一次,把每条命令记下来有一个能贴进脚本的命令序列
2把命令序列写成服务器上的 deploy.sh在服务器上执行成功
3deploy.sh 的调用放进 CI,手动触发点一次按钮能发布成功
4加 lint、测试、构建阶段提交坏代码时流水线变红
5加产物归档、回滚脚本、通知能一条命令切回上一版本

「先手动跑通,再自动化」——反过来做,大量时间会浪费在调试流水线而不是交付功能。

常见坑

现象做法
测试失败仍继续部署坏代码上生产needs/stages 强制串行依赖
ssh-keyscan 放在流水线里首次信任可被中间人利用known_hosts 内容存 Secret
私钥写进仓库或日志密钥泄露,服务器被入侵只放 Secrets,禁止 echo 整份私钥
CI 用 root 部署一把钥匙等于整机权限专用用户 + 只允许写目标目录 + 单条 sudo
每次都全量上传慢,且网络抖动容易传一半rsync -avz --delete,传到新目录再切换
直接覆盖线上目录用户访问到半成品部署到带版本的新目录,软链原子切换

小结:CI/CD 的最小闭环是「触发条件 + 依赖缓存 + lint/测试 + 构建产物 + rsync 增量部署 + 软链切换 + 健康检查」,GitHub Actions 与 GitLab CI 只是关键字不同,结构可以照搬;密钥一律走 Secrets,known_hosts 也用 Secret 存,CI 部署用专用用户并只授权写目标目录与 reload Nginx;落地顺序一定是「手工跑通 → 服务器脚本化 → CI 调用 → 加测试与回滚」,先要可靠,再要快。

笔记加载中…