如果你维护过哪怕一个有点用户量的项目,大概率经历过这种场面:发版前先在本地把测试跑一遍,再手工打一次包,传到服务器,然后 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_USER | SSH 登录用户名 |
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 报错时,第一个动作永远是打开运行记录页面,看具体是哪一步失败了。日志是逐条输出的,红色日志上方通常有一条粗略的错误摘要。我总结了三个步骤:
- 看日志:定位是 YAML 解析失败、命令报错,还是权限问题。前 10 行日志里基本能判断方向。
- 本地复现:把 failed Step 里的命令拉到本地跑一次。很多 CI 报错只是因为环境变量、Node 版本或 lock 文件差异,本地跑通后再反过来对照 workflow 里的上下文。
- 加打印:在不影响安全的前提下,把关键变量
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 allowed | YAML 冒号后缺少空格 | 全文检查冒号和空格,尤其注意多层嵌套 |
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 多顺手,都要理解“自动化不是银弹”。我自己的习惯是,每个仓库至少保留一个手动触发入口和一个查看实时日志的习惯:假如哪天自动环节老化、失败范围扩大,至少还能靠人踩刹车。把自动化当成可靠的工具,而不是不需要思考的替身,这才是能干得长远的心态。