GitHub Actions 实战:从构建、测试到自动部署的完整流水线

很多人对 GitHub Actions 的印象还停留在「加个 CI 跑跑测试」,但真正让它值钱的是从 push 代码到部署上线一条龙自动跑完。这篇不讲概念,直接带你搭一条能用于生产的流水线:代码推上去 → 装依赖 → 跑测试 → 构建镜像 → 推到镜像仓库 → SSH 到服务器滚动更新。中间会踩的坑(缓存不生效、secret 泄露、部署一半挂了没人知道)一个个填掉。

先看一个「能跑但很烂」的 workflow

新手常写出这样的东西,放在 .github/workflows/deploy.yml:

name: deploy
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install
      - run: npm test
      - run: npm run build
      - run: scp -r dist/ user@server:/var/www/  # 密码硬编码在哪?

问题一堆:

  • on: push 会在任何分支、任何提交都触发,改个 README 也跑一遍部署。
  • 每次都 npm install 全量装,没缓存,一次三五分钟。
  • scp 部署没有任何鉴权配置,真要写密码只能塞明文,等于把服务器密码公开。
  • 测试挂了会不会继续部署?这里靠 step 顺序碰运气,没有明确的 job 依赖。

下面一步步改成正确的样子。

一、精确控制触发条件

只在 main 分支、且改动了代码时才触发,并且手动也能点一下跑:

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'          # 只改文档不触发部署
      - 'docs/**'
  workflow_dispatch:      # 允许在 Actions 页面手动触发,方便回滚重跑

workflow_dispatch 强烈建议加上——部署流水线经常需要「不改代码但重新跑一次」,比如上次部署中途网络抖了。

二、把 CI 和 CD 拆成两个 job,用依赖串起来

关键点:测试不过,绝不部署。用 needs 建立依赖,而不是把所有步骤堆在一个 job 里靠顺序。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'        # 关键:自动缓存 ~/.npm,下次装依赖秒级
      - run: npm ci           # ci 比 install 快且严格,锁定 lockfile
      - run: npm test

  deploy:
    needs: test               # test 这个 job 成功后才会跑 deploy
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'   # 双保险,只有 main 才部署
    steps:
      - uses: actions/checkout@v4
      # ... 见下文

这里有两个容易忽略的点:

  1. cache: 'npm'setup-node 内置的缓存能力,不用再手写 actions/cache。它按 package-lock.json 的 hash 做 key,lockfile 没变就直接命中。
  2. npm ci 而不是 npm install:CI 环境要的是可复现,ci 会严格按 lockfile 安装,lockfile 和 package.json 对不上直接报错,能提前发现依赖漂移。

三、构建 Docker 镜像并推送

假设我们用容器部署。这里用官方的 docker/build-push-action,并且给镜像打上 commit sha 作为 tag(方便精确回滚):

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: 登录镜像仓库
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # 内置 token,推到 ghcr 够用

      - name: 构建并推送
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha           # 复用上次构建的 layer 缓存
          cache-to: type=gha,mode=max

cache-from/cache-to: type=gha 用的是 GitHub Actions 自带的 layer 缓存,Docker 构建慢的项目开了它能省一大半时间。${{ github.sha }} 这个 tag 很重要:出了问题你可以直接用某个 sha 的镜像回滚,而不是只有一个飘忽不定的 latest

四、SSH 到服务器滚动更新(secret 的正确姿势)

绝对不要把私钥、服务器 IP 写进 yaml。全部走 Repository Secrets(仓库 Settings → Secrets and variables → Actions 里配),在 workflow 里用 ${{ secrets.XXX }} 引用:

      - name: 部署到服务器
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}      # 私钥内容,不是路径
          script: |
            set -e
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker stop app || true
            docker rm app || true
            docker run -d --name app --restart=unless-stopped \
              -p 8080:8080 \
              ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker image prune -f    # 清掉旧镜像,别把磁盘撑爆

几个救命细节:

  • set -e:脚本里任何一条命令失败就立刻退出并让这一步标红。不写它的话,docker pull 失败了后面还会继续跑,最后「绿了」但服务其实没起来,这是最坑的假成功。
  • key 传的是私钥内容本身,把 ~/.ssh/id_ed25519 的完整内容(含 -----BEGIN... 那几行)粘到 secret 里。对应的公钥要提前加到服务器的 ~/.ssh/authorized_keys
  • secret 会在日志里被自动打码,即使你不小心 echo 出来,GitHub 也会显示成 ***。但别依赖这个,不要主动打印 secret。

五、部署失败要有人知道

流水线最忌讳「悄悄挂了」。加一步失败通知,if: failure() 只在前面步骤失败时才执行:

      - name: 部署失败通知
        if: failure()
        run: |
          curl -X POST "${{ secrets.WEBHOOK_URL }}" \
            -H 'Content-Type: application/json' \
            -d "{\"text\":\"🚨 部署失败: ${{ github.repository }} @ ${{ github.sha }}\"}"

if: failure() 是关键——默认情况下前面步骤挂了,后续步骤会被跳过,加上这个条件它反而只在失败时触发,正好用来报警。

六、并发控制:别让两次部署互相踩

连续 push 两次,可能触发两条部署同时跑,后推的镜像先部署完、先推的后覆盖,状态就乱了。用 concurrency 让新的取消旧的:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true    # 同一分支有新部署时,取消正在跑的旧部署

放在 workflow 顶层(和 onjobs 平级)。这样永远只有最新那次部署在跑,避免竞态。

小结

一条靠谱的部署流水线,核心就这几条:

  • 触发要精确:限定分支 + paths-ignore 过滤无关改动 + 保留 workflow_dispatch 手动入口。
  • CI/CD 分 job 并用 needs 串联:测试不过绝不部署,依赖关系显式声明,不靠 step 顺序碰运气。
  • 善用缓存:setup-nodecache: npm 和 Docker 的 type=gha 缓存,能把几分钟的流水线压到几十秒。
  • secret 只进 Secrets,私钥传内容不传路径,脚本首行 set -e 防假成功。
  • 失败要报警(if: failure())、并发要控制(concurrency)

一句话记忆点:流水线的价值不在「能跑」,而在「挂了拦得住、失败叫得醒、回滚有得选」

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐