☰
GitHub Actions 实战:从 Workflow 编写到 CI/CD 自动部署
2026/10/7 17:19:43 网站建设 项目流程

如果你维护过哪怕一个有点用户量的项目,大概率经历过这种场面:发版前先在本地把测试跑一遍,再手工打一次包,传到服务器,然后 SSH 进去重新部署。过程不复杂,但每次都一样,而且无数次验证了同一件事——人做重复的事一定会出错:忘了跑测试、漏传文件、部署到一半网络断了。GitHub Actions 就是替你把这一切写成代码、随仓库一起保存、到点自动执行的工具。它不需要单独部署一台 Jenkins,不依赖你本地环境,只用一份 YAML 文件定义流程,push 代码的瞬间任务就在 GitHub 的服务器上跑起来。

这篇文章不打算讲花哨的语法糖。我会从自己的使用经验出发,把它拆成几块:它到底解决什么问题、第一份 workflow 怎么写、怎么从测试一路走到自动部署、以及运行的时候会踩到哪些坑。适合谁看?写过 Git、对 CI/CD 有概念但没上手的人,或者用过其他 CI 工具、想看看 GitHub 这套有什么不同的人。看完之后,你应该能独立写出带测试、打包、部署的完整流程。

1. 先搞清楚 GitHub Actions 到底在帮你做什么

1.1 它消灭的是什么样的重复劳动

我最早对自动化的需求特别朴素:每次 push 代码之后,想自动跑一遍测试;每次打个v1.0.0的 tag,想自动生成一份 release notes;每次凌晨两点,想把线上数据抓下来存到仓库里做分析。这些事单独看都不难,难在“每次都记得做”。

GitHub Actions 解决的就是这个“记得”的问题。你定义好触发条件,剩下的交给它:往 main 分支推代码就跑测试,提 PR 就做静态检查,定时任务到点就执行,打 tag 就自动发版。它像厨房里的智能电饭煲,设定好流程,到点自己做,不会忘。

对比下来,手动流程的痛点非常清晰:

  • 依赖某个人的本地环境,别人跑不起来;
  • 看不到历史记录,出了问题说不清哪一步错的;
  • 人容易偷懒,时间一长步骤就变样了。

放进 GitHub Actions 之后,流程变成了仓库的一部分。任何人 checkout 代码,都能看到.github/workflows/里躺着什么任务,每一步的日志都能回放,谁改了流程也清清楚楚。这本身就是一种工程规范:把“口头约定”升级成“代码约定”。

1.2 一次性搞懂核心概念:Workflow / Job / Step / Runner / Action

GitHub Actions 的核心概念不多,但第一次接触容易被术语绕晕。我用自己的话说一遍。

  • Workflow:一份.yml配置文件,放在.github/workflows/目录下。它定义“什么时候开始干活”和“具体干什么活”。
  • Event:触发 Workflow 的事件。最常见的push、pull_request,还有手动触发workflow_dispatch、定时触发schedule。
  • Job:Workflow 里的一个执行单元。一个 Workflow 可以有多个 Job,默认情况下它们在各自的 runner 上并行跑。
  • Step:Job 里的具体动作。一个 Step 可以是一条 shell 命令,也可以是一个 Action。
  • Runner:真正干活的机器。通常用 GitHub 托管的ubuntu-latest,也可以用 Windows、macOS,甚至你自己维护的自托管机器。
  • Action:别人封装好的“积木”。比如actions/checkout@v4负责把代码拉下来,actions/setup-node@v4负责装 Node.js。你不用自己写全套命令,直接uses就行。

串起来就是:某个 Event 发生了,GitHub 在 Runner 上启动一个或多个 Job,每个 Job 按顺序执行一串 Step,Step 里可以夹带命令和 Action。就这么简单。

概念虽然简单,但我建议你在写第一个 Workflow 前,先在仓库的Actions 标签页里点开几个别人项目的运行记录看一遍。你会直观看到“哪一步花了多少秒”“哪一步挂了”“日志长什么样”,比读十篇文档都有用。

1.3 为什么个人项目也值得上 Actions

很多人觉得“自动化工具是大团队才需要的”,其实个人项目恰恰是收益最大的场景。免费额度对个人完全够用:公共仓库免费跑,私有仓库每个月也有至少 2000 分钟的免费额度。对个人开发来说,一个月能跑掉这 2000 分钟,说明你的项目已经活跃到相当程度了。

另一个理由是生态。GitHub Marketplace 上有几十万个 Action,从部署到服务器、发邮件通知、生成二维码、同步到云存储,都有现成的。你写 CI 时基本不用从零发明,搜一下、配一下、点个赞,就能用起来。

还有一点很实际:它和代码评审绑在一起。PR 页面上直接显示 CI 是否通过,维护者看到绿色勾勾才敢点 Merge。对一个开源项目来说,这比在 README 上贴一堆“质量达标”的标签更有说服力。对团队来说,它也让“测试在本地通过了”这句话不再具有意义——所有检查都跑到云端统一环境里做,本地过了不算数。

2. 从零写第一个 Workflow:请抓牢这三个字段

2.1 新文件放哪里,以及 YAML 书写避坑

工作流文件必须放在仓库根目录下的.github/workflows/里,文件名随意,.yml和.yaml后缀都行。我的习惯是按用途命名:纯测试的叫ci.yml,部署相关的叫deploy.yml,定时任务叫schedule.yml。一个仓库可以放多个文件,GitHub 会全部识别。

第一行建议先写一个名字:

name: CI

之后的核心字段只有一个,别的都能查文档补。编码时我建议专心把握三个字段:on(什么时候跑)、jobs(跑什么)、steps(怎么跑)。

YAML 是这类文件的硬门槛,第一次写很容易栽在格式上。几个血泪经验:

  • 缩进必须用两个空格,不要用 Tab。连续多层嵌套时一乱,YAML 解析器直接报错。
  • 冒号后面必须跟一个空格,比如name: CI而不是name:CI。
  • 列表项用-开头,和下面的 key 保持同一级缩进。
  • 字符串如果包含特殊字符(比如*、{),最好用单引号包起来。

我见过太多“日志提示 YAML syntax error”的情况,十有八九是缩进问题。提交前养成一个好习惯:在本地编辑器里开启 YAML 语法高亮,或者直接用 VS Code 的插件检查格式。

2.2 触发事件:on 字段的几种常用姿势

on字段决定了工作流什么时候启动,它是 Workflow 的“开关”。最常见的写法是:

on: push: branches: - main pull_request: branches: - main workflow_dispatch:
  • push在推代码时触发,配合branches过滤。只写branches: [main],就是说只有 push 到 main 分支才跑。
  • pull_request在提 PR 和更新 PR 时触发。默认行为是 opened、synchronize、reopened 三种事件都会触发,一般够用。
  • workflow_dispatch是手动触发。加上它之后,Actions 页面右上角会出现一个 “Run workflow” 按钮,随时能手动跑一次。这个入口强烈建议每次都留着,后面调试会感恩自己当时没偷懒。

还有两个容易被忽略的过滤参数:

on: push: paths: - 'src/**' - '.github/workflows/*'

paths表示只有改动指定路径时才跑,适合“只改 README 就不跑 CI”的场景,能省不少分钟数。我个人的项目里,文档、图片、配置类的改动根本不需要触发完整测试线。

定时任务用schedule:

on: schedule: - cron: '0 2 * * *'

这里有个几乎人人中过的坑:cron 用的是 UTC 时间。如果是北京时间,得在 UTC 基础上加 8 小时。想每天北京时间上午 10 点跑,cron 就要写'0 2 * * *'。我早年没注意时区,定时任务连着好几天都在“错误的凌晨”执行。

2.3 一个足够日常用的 Node.js CI 示例

下面这份配置是我个人项目的模板,注释标了每一段的用途:

name: CI on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: jobs: build: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: 拉取代码 uses: actions/checkout@v4 - name: 安装 Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: 安装依赖 run: npm ci - name: 跑测试 run: npm test - name: 构建 run: npm run build

逐行拆开说:

runs-on: ubuntu-latest是 runner 选择,最常用、免费额度内最稳。timeout-minutes: 20是个好习惯,防止某个 Step 卡死,白白吃掉一两个小时。

第一步actions/checkout@v4几乎是所有工作流的第一步,它把当前仓库代码拉到 runner 上。没有它,后面的命令面对的是空目录。第二步actions/setup-node@v4负责装 Node,with.node-version指定版本,with.cache: 'npm'会自动缓存~/.npm,下次安装依赖会快很多。

npm ci是我强烈推荐的安装方式。它和npm install的区别在于:它严格按package-lock.json安装,删除node_modules后全新安装,保证生产构建和本地高度一致。前提是仓库里有 lock 文件。如果没有,第一次需要先运行npm install生成一份并提交。

跑测试和构建没什么特殊之处,就是普通命令。失败时这个 Step 会标红,整个 Job 自动失败,PR 上直接显示红色叉号。

如果需要在多个 Node 版本下验证,intro 一下 matrix:

strategy: matrix: node-version: [18, 20, 22]

它会让这个 Job 在三个版本下各跑一遍,适合给兼容性要求高的库用。实际要写很多版本时,我一般收窄到最常用的 2-3 个,避免节奏被拖慢。

3. 从 CI 走向 CD:把自动部署也写进去

3.1 部署方案:选定一种,别一开始就追求复杂

CI 跑通之后,下一步自然是“测试过了就直接上线”。GitHub Actions 做部署的思路,和本地手动部署没什么两样,只是把命令搬到了云端。

最常见也最好上手的模式:Workflow 通过 SSH 连到服务器,在服务器上执行拉代码、装依赖、重启服务。

另一种模式是服务器主动拉取:服务器上挂一个 webhook 或定时脚本,检测到仓库有更新就自己拉代码。这种方式对 GitHub Actions 的依赖更少,但多了部署机的配置成本。对个人项目来说,没有分布式、没有多节点,我建议先选 SSH 模式,直观、容易排查。

为什么不直接把整个代码目录塞到服务器上?因为生产环境通常需要经过构建、压缩、迁移,这些动作在服务器上执行更可控。GitHub Actions 负责触发和传递参数,最终落地由服务器完成。

3.2 配置 Secrets:这一步决定安全性

关键问题是:SSH 私钥、服务器密码这些敏感信息,绝对不能写进.yml文件,也不能出现在日志里。

GitHub 提供了 Secrets 功能。仓库页面进入Settings → Secrets and variables → Actions,点击 New repository secret,添加一个键值对。之后在 workflow 里用${{ secrets.XXX }}引用。

我一般会建这几个:

Secret 名称用途
SERVER_HOST服务器 IP 或域名
SERVER_USERSSH 登录用户名
SSH_PRIVATE_KEY部署用的 SSH 私钥

处理私钥时有一个特别容易翻车的细节:粘贴私钥如果带有换行,GitHub Secrets 表单会自动按单行处理一部分,导致 PEM 格式被破坏,登录时直接报 “Permission denied”。我的做法是:先在本地把私钥文件 base64 编码或者用cat ~/.ssh/deploy_key原样复制,粘贴到 Secrets 时确保不手动改任何字符,多测几次就稳了。

另外,Secrets 有个安全限制:来自 fork 仓库的 PR 默认无法读取仓库的 secrets。外部贡献者提的 PR 不能通过工作流直接拿到你服务器的 access token。这是 GitHub 的安全设计,不是 bug。遇到这种情况,通常的处理方式是对 fork PR 只跑测试类 CI,不跑部署类任务,或者等代码合并进 main 分支后再由 main 上的 workflow 执行部署。

3.3 一个可以直接抄的部署示例

下面这份deploy.yml假设:代码在服务器/var/www/myapp目录,使用 PM2 管理进程,仓库私钥存在SSH_PRIVATE_KEY里。

name: Deploy on: push: branches: - main paths: - 'app/**' - '.github/workflows/deploy.yml' concurrency: group: production cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest environment: production steps: - name: 拉取代码 uses: actions/checkout@v4 - name: 部署到服务器 uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /var/www/myapp git pull origin main npm ci npm run build pm2 restart myapp

这里有几个值得留意的细节:

concurrency是一个非常容易被忽略的字段。它的作用是防止两个 Job 同时跑同一套流程。比如你连续 push 两次,第二次还没等第一次部署完就开始,两个 Job 同时在服务器上 git pull,很容易出现代码错乱。我设置cancel-in-progress: false是为了让部署任务排队完成,而不是被打断。CI 类任务则相反,通常设true,因为旧测试没必要跑完。

environment: production声明这是一个环境级别的部署。配合 GitHub 的环境保护规则,你甚至可以设置成 “main 分支发生变更后,还需要人工批准才真正执行”。对个人项目来说有点重,但对团队项目,这是防止误操作的关键防线。

appleboy/ssh-action@v1.0.3是社区里广泛使用的 SSH 执行工具。你也可以不用它,直接跑ssh user@host 'cd /var/www/myapp && git pull origin main && npm ci && npm run build && pm2 restart myapp',效果一样。区别在于它自动处理了 key 认证、连接保持、超时控制这些麻烦事。

在服务器端,一定要保证 git 拉取不使用密码。推荐在服务器上为部署用户单独配一个deploy key,只读访问仓库,这样git pull不会因为密码输入而卡住。

3.4 把它变成积木:Reusable Workflows 的初体验

当你有多个项目要维护,会发现部署逻辑高度相似:拉代码、装依赖、跑构建、发通知,只是仓库名和服务器地址不同。复制粘贴当然能解决问题,但每次都要改一堆行,而且模板升级时要在每个仓库里同步改一遍。

GitHub 的 Reusable Workflows 能解决这个问题。它可以被其他 Workflow 调用,接收参数和 Secrets,把公共逻辑收拢到一处。

被复用的 workflow 文件开头要这样写:

name: Reusable Deploy on: workflow_call: inputs: deploy_path: required: true type: string secrets: SERVER_HOST: required: true SERVER_USER: required: true SSH_PRIVATE_KEY: required: true jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd ${{ inputs.deploy_path }} git pull origin main npm ci npm run build pm2 restart myapp

然后在普通 workflow 里调用它:

jobs: call-deploy: uses: yourname/yourrepo/.github/workflows/reusable-deploy.yml@main with: deploy_path: /var/www/myapp secrets: SERVER_HOST: ${{ secrets.SERVER_HOST }} SERVER_USER: ${{ secrets.SERVER_USER }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

变化点通过with传入,敏感信息通过secrets传入。团队的公共部署逻辑只要维护一个仓库,其他项目引用即可。我实际用下来的感受是:前几次写麻烦一点,等维护到第三个项目时,省的心是成倍增长的。

4. 高频报错与排查记录:这些坑我替你踩了

4.1 排查报错的基本思路

遇到 Actions 报错时,第一个动作永远是打开运行记录页面,看具体是哪一步失败了。日志是逐条输出的,红色日志上方通常有一条粗略的错误摘要。我总结了三个步骤:

  1. 看日志:定位是 YAML 解析失败、命令报错,还是权限问题。前 10 行日志里基本能判断方向。
  2. 本地复现:把 failed Step 里的命令拉到本地跑一次。很多 CI 报错只是因为环境变量、Node 版本或 lock 文件差异,本地跑通后再反过来对照 workflow 里的上下文。
  3. 加打印:在不影响安全的前提下,把关键变量env或文件路径打印出来。每次排错时我都会临时加一行run: env或run: ls -la看看环境状态。

另外一个小习惯:在 Step 里指定 shell 时,把调试模式打开。比如run前面加shell: bash -x {0},脚本里的每一行命令都会回显出来,一目了然。注意日志可能暴露密钥,调试完务必删除。

4.2 高频问题速查表

现象常见原因解决方法
Workflow 完全没有运行on里分支名写错,或格式不对检查默认分支名(main 还是 master),加上workflow_dispatch手动重跑验证
日志报mapping values are not allowedYAML 冒号后缺少空格全文检查冒号和空格,尤其注意多层嵌套
npm ci报 lock 文件不存在仓库没有提交package-lock.json本地先npm install生成 lock 文件并提交
${{ secrets.XXX }}打印出来是空fork 的 PR 拿不到 secrets;环境变量命名拼错确认运行环境是否安全,检查 Secret 是否在正确仓库
SSH 部署报Permission denied (publickey)私钥和公钥不匹配;服务器用户不对;私钥格式坏了重新上传正确的私钥,确认服务器用户与公钥一致
定时任务不执行cron 时区记错;仓库不活跃;写在非默认分支换算北京时间,确认 schedule 在默认分支,手动触发一次排除工作流本身问题
Job 长时间排队私有仓库并发配额受限减少冗余 workflow,用concurrency合并重复运行
磁盘空间不足缓存和日志累积过多在 Job 末尾加清理步骤,或缩短缓存保留时间

这张表覆盖了我被问到最多的几类问题。第七行“Job 长时间排队”在小团队里不太明显,但如果你把 Actions 用到极致,比如每个 push 都触发好几个 workflow,私有仓库免费额度下的并发限制很快会让你体验一把“排队两小时,构建五分钟”的感觉。

4.3 本地模拟:用 act 减少反复提交

Actions 有一点很烦人:每次调试都要 push 一次代码,才能在 GitHub 服务器上跑。如果 workflow 逻辑复杂,改一次、提交一次、等两分钟,一天下来几十次 commit 就为了看一个结果,效率低且日志混乱。

有个社区工具叫act(nektos/act),它基于 Docker 在本地模拟 GitHub Actions 的运行环境。用它可以执行本地目录里的工作流,无需真正推到 GitHub。

安装很简单:

brew install act

然后进入项目目录:

act -j build

-j build表示只跑名为 build 的 Job,日常调试时省时间。它默认会下载 Docker 镜像,首次运行较慢,第二次之后就有缓存了。

用 act 时要注意:它不会完全复刻 GitHub 的某些行为,比如 repository secrets、环境变量、某些官方 action 的细节行为。但对run: npm ci、run: npm test这类核心逻辑的验证完全够用。我自己的习惯是:先在本地跑 act 确认逻辑无问题,再 push 到 GitHub 上看最终结果。这个“先本地后远程”的操作习惯,让我省掉了大量无效 commit。

act 无法完整模拟 secrets,所以涉及部署类的 job 我一般不会用 act 跑,只在本地验证纯构建、纯测试类 job。

5. 从会用到好用:几个建议你早点养成的习惯

5.1 控制资源成本的两个开关

免费额度再多,也是有限的钱。我见过不少人一个月把 2000 分钟跑穿,因为每个 push 都触发全量 CI、每个 job 都要开 4 个 step 的缓存重建。两个开关能立竿见影:

第一个是concurrency。设置 work 路由组后,同一时间只保留一个运行实例,这样频繁 push 时旧任务会自动取消,不会堆积。

concurrency: group: ci-${{ github.ref }} cancel-in-progress: true

第二个是paths过滤。只改文档、只改配置时,不触发完整构建链。我在 2.2 节已经给了写法,这里再强调一次:它能让你省下至少 30% 的分钟数,而且“该跑的时候才跑”更符合直觉。

5.2 自动化也要可观察、可回退

把部署全交给 Actions 之后,风险也变了:以前是我手动操作可能忘,现在系统自动化可能错,而且会安静地错到不可收拾。所以必须配上可观察机制。

至少做三件事:

  • 失败通知:在 workflow 末尾加一个if: failure()的 Step,用邮件或钉钉 Webhook 把失败信息送出去。这样线上有问题时,你不用每次主动去刷 Actions 页面。
  • 部署前备份:在服务器上的部署脚本里,把发布前目录打个 tar 包或做数据库备份。自动化越顺手,越要给自己留一条回退的路。
  • 保留重跑能力:记得给每个 workflow 留workflow_dispatch,它不只用于调试,也用于“线上挂了,手动热修复后立刻重新部署”的紧急场景。

if: failure()的实现大致这样:

- name: 发失败通知 if: failure() run: curl -X POST https://your-webhook-url -d '{"msg": "CI failed"}'

5.3 给 workflow 做减法,保持可维护

我见过一团乱的 workflow:一个 job 里塞了二十个 step,从安装依赖到发通知全挤在一起;另一个极端是滥用复用,把五个项目塞进一个 workflow,牵一发而动全身。理想状态是“既按职责拆开,又不碎片化”。

我的习惯拆法:

  • CI workflow:代码进来就跑测试,执行频率高,内容稳定。
  • CD workflow:部署到指定环境。通过environment区分 staging 和 production,通过needs依赖 CI 的结果,CI 不通过就不部署。
  • Schedule workflow:专门放定时任务,跟 push 触发完全隔离。

这样拆开之后,每个文件职责单一,修改某一类逻辑时不用在大文件里找半天。

Action 版本也要管好。uses: actions/checkout@v4里的@v4是主版本号,跟上大版本升级是基本操作;想完全锁定,可以用@<commit-sha>做不可变引用。社区 action 和维护者的公信力密切相关,尽量不要用下载量少到可疑的冷门 action,特别是它要读取你有权限的 token 的时候。

最后,无论 workflow 多顺手,都要理解“自动化不是银弹”。我自己的习惯是,每个仓库至少保留一个手动触发入口和一个查看实时日志的习惯:假如哪天自动环节老化、失败范围扩大,至少还能靠人踩刹车。把自动化当成可靠的工具,而不是不需要思考的替身,这才是能干得长远的心态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询