前端项目真正从开发到上线,最容易被忽略的其实是最后那几步。我见过不少团队,代码写得很规整,技术栈也很新,结果每次上线都是同一个人下午四点半开始手动打包、压缩、传服务器、清 nginx 缓存……速度快不快完全取决于这个人今天状态好不好。上了一台新服务器,或者换了交接人,那更是灾难。这篇文章就完整记录我是怎么把前端项目的 GitLab 自动化部署链路从零搭起来的,核心就三件事:把“人肉上线”变成“提交代码自动触发”,把每次发版信息留在流水线里,让任何一台机器、任何一个人点一下按钮就能复现同一次构建。
1. 前端的发布流程为什么需要一只“看不见的手”
1.1 手动部署的三个真实痛点
先说个我自己的例子:项目早期,每次发版都是“打包三连”——本地跑npm run build,等两分钟,然后把整个 dist 目录用 scp 甩到服务器上。听着挺简单,对吧?真正跑起来之后你会发现三个非常现实的问题。
第一,不可重复。本地机器和服务器、和同事的机器构建出来的产物经常有细微差别。node 版本不同、npm 版本不同、环境变量没对齐,都会导致“在我电脑上是好的,传上去就有问题”。我们甚至碰到过因为某台电脑的.npmrc里配了公司私有源,结果构建产物里混进了只有内网才能访问的资源引用,上线后外网用户死活加载不出来。
第二,人肉流程天然依赖某个“神”。谁手里有服务器密码、谁知道发布步骤、谁记得要清缓存,这个人一请假,整个上线就卡住了。更可怕的是这种知识往往只存在于某个人的聊天记录或者本地便签里,别人问起来,得到的答复多半是“你等我看看”。
第三,没有审计。上线之后出了问题,想查看“服务器上当前跑的是哪次 commit、构建时间是什么时候”,得靠回忆。如果上线前后动过好几次,甚至没人能说清现场是个什么状态。
这些痛点和“自动化”本身有多高级没关系,它们逼着你思考一件事:发布这件事,能不能变成一条固定流程,让人只是触发它、观察它,而不是亲手把每一步做一遍。
1.2 自动化部署解决的不只是速度问题
很多人以为上 CI/CD 就是为了“快”,其实快只是副产品。它真正解决的是可重复、可追溯和可回滚。
打一个最直观的比方:手动部署就像每天手工抄报表,抄得再快也可能抄错行;CI/CD 则像是打卡系统,规则写在系统里,谁打没打卡、几点打的、打了什么位置,全有记录。
放到 GitLab 这套体系里,每次 merge 或 push 触发流水线后,日志里会清清楚楚地记录这次构建是哪个 job、由哪个 commit 触发、构建产物 hash 是多少、部署到了哪台服务器。之后不管是指着某次提交说“我就要这个版本”,还是凌晨两点排查线上事故,这些记录都是救命线索。
所以这篇文章面向的读者很明确:手上有 GitLab 仓库、前端的构建产物还是靠人工传服务器,或者刚接触 CI/CD 一脸懵,想把项目完整接进去的前端同学。下文所有步骤和配置,都是我实际在项目里跑过的,可以直接参考。
2. 先把地基打好:GitLab 和 Runner 的安装与连接
2.1 快速部署一个 GitLab 服务(Docker 方式)
如果你是直接用公司的 GitLab,那这一步可以跳过。但很多人是在自己的服务器上搭环境练手,这时候最省心的方案是用 Docker 起一个社区版实例。社区版的 CI/CD 功能是完整的,跟企业版在基础的 pipeline 使用上没有本质区别。
下面是我实际用过的一份 docker-compose 配置,端口做了常见调整,避免跟宿主机上已有的服务冲突。
version: '3.8' services: gitlab: image: gitlab/gitlab-ce:latest container_name: gitlab restart: always hostname: gitlab.example.com ports: - "8080:80" - "8443:443" volumes: - /srv/gitlab/config:/etc/gitlab - /srv/gitlab/logs:/var/log/gitlab - /srv/gitlab/data:/var/opt/gitlab shm_size: '256m'这里有两个容易踩的细节。
一是端口映射。很多人习惯把容器内的 80 直接映射到宿主机的 80,但如果宿主机上已经跑了 nginx 或者 Apache,端口冲突会导致 GitLab 起不来。所以我用了8080:80,访问地址就是http://gitlab.example.com:8080。
二是hostname和external_url的关系。GitLab 很多内部操作(比如生成 clone 地址、回调 URL)是根据“它认为自己的地址”来生成的。如果你只是局域网访问,最好在 gitlab.rb 里显式设置:
external_url 'http://gitlab.example.com:8080'改完这个配置需要执行gitlab-ctl reconfigure。这一步不做,后面极有可能出现仓库 clone 地址变成容器内部 IP 之类的怪问题。
版本方面想多说一句。GitLab 老版本和新工具链的兼容性问题非常多,后面排查部分我会讲到。我自己现在部署新实例一律用gitlab-ce:latest或明确的 16.x、17.x 标签,坚决不用 14 以下的版本——不是炫新,是新版工具(IDE 插件、CLI)默认使用的 API 在旧版上根本跑不通。
2.2 Runner:流水线的实际执行者
GitLab CI/CD 的架构是“GitLab 平台 + 独立的 Runner 进程”。GitLab 本身只负责定义流水线、展示日志,真正跑npm run build、执行rsync的是 Runner。它需要单独装,不能省略。
Runner 可以装在和 GitLab 同一台机器,也可以装在单独的机器上。个人项目练手阶段直接同机装最简单。用 Docker 方式装 Runner 也很快,但注意一个关键点:Runner 注册时选择的 executor 决定了每个 job 怎么执行。
docker executor是最常用的模式——每个 job 都启一个独立的容器来干活,环境互不隔离。注册命令大概是这样:
gitlab-runner register \ --url http://gitlab.example.com:8080 \ --registration-token <项目或实例的token> \ --executor docker \ --docker-image node:20-alpine \ --description "node-runner" \ --tag-list "web,node,frontend"注册完成后,到 GitLab 的管理后台(Admin Area -> CI/CD -> Runners)看一眼,能看到这台 Runner 是绿色的在线状态,就可以继续往下走了。
这里需要解释一下tag-list的作用。GitLab 的 job 可以通过tags字段锁定在特定 Runner 上跑,比如构建任务要求那个 Runner 能访问内网、或者只有它装了特定证书。你给 Runner 打的 tag 和 job 里的 tags 必须形成匹配关系,否则 job 会一直卡在 pending 状态。我习惯给 Runner 打上web,node,frontend这样语义化的标签,后面配置.gitlab-ci.yml时一眼就能认出每个 job 归谁执行。
2.3 个人访问令牌与 CI/CD 变量
GitLab 有几种令牌:注册 Runner 用的 registration token、用户登录用的 Personal Access Token、管道专用的 CI_JOB_TOKEN、部署用的 Deploy Token。日常个人使用,有 Personal Access Token 就够了,它的权限范围可以精确到api、read_repository、write_repository等。
创建位置在个人头像 -> Preferences -> Access Tokens,勾上你需要的 scope,生成后立刻复制保存——这个令牌只显示一次,页面刷新就没了,丢了只能删掉重建。
令牌不是给你每次都手动填的,它的主要使用场景是配 IDE 插件、GitLab CLI,以及某些 CI job 需要操作 Git 仓库时使用。真正在流水线里用的敏感信息,应该放到项目的Settings -> CI/CD -> Variables里。
我梳理了一个前端项目部署最常用的变量清单,供你参考:
| 变量名 | 用途 | 是否需要 Masked |
|---|---|---|
| SSH_PRIVATE_KEY | 部署阶段 SSH 登录服务器的私钥 | 是 |
| SERVER_HOST | 目标服务器 IP 或域名 | 建议 |
| SERVER_USER | SSH 登录用户 | 建议 |
| SERVER_PATH | 服务器上部署目录 | 否 |
| DEV_SERVER_HOST / DEV_SERVER_PATH | 测试环境专用变量 | 否 |
| PROD_SERVER_HOST / PROD_SERVER_PATH | 生产环境专用变量 | 否 |
SSH 私钥的生成和配置是部署环节里最容易出错的一步,下面单开一节细讲。
3. .gitlab-ci.yml:前端流水线的核心配置
3.1 pipeline 的四个阶段怎么划分
GitLab CI 里的 job 要按照 stage 来组织,stage 之间是串行的,同一个 stage 里的 job 可以并行。前端项目我常用的划分是四个阶段:install、lint、build、deploy。
install 抽出来单设一个 stage 是有讲究的。前端依赖安装特别吃 IO,如果每个 job 都各自跑一遍npm install,流水线会慢得让人想摔键盘。把它单独拉出来,再配合 artifacts 把node_modules传给后面的 job,整个流程只会安装一次。lint 和 build 可以在 install 完成后并行跑,省时间。deploy 永远排在最后,确保只有构建产物成功产出之后才会往服务器上推。
四个阶段对应到流水线的实际流程就是:安装依赖 -> 静态检查 -> 打包 -> 发布。这个顺序不是拍脑袋定的,它保证了一个最朴素的原则:绝不带着垃圾构建产物去部署。代码风格有问题、测试没过,build 都不会触发,更碰不到服务器。
3.2 一份可直接改的前端 CI 配置
下面这份配置是我目前前端项目在用的简化版,技术栈是 Vue/React 都通用,只要构建命令是npm run build,几乎可以照搬。
stages: - install - lint - build - deploy variables: NODE_ENV: production NPM_CONFIG_REGISTRY: "https://registry.npmmirror.com" cache: key: "$CI_COMMIT_REF_SLUG" paths: - node_modules/ before_script: - node -v - npm -v install: stage: install image: node:20-alpine script: - npm ci artifacts: paths: - node_modules/ expire_in: 1 day lint: stage: lint image: node:20-alpine script: - npm run lint dependencies: - install build: stage: build image: node:20-alpine script: - npm run build artifacts: paths: - dist/ expire_in: 7 days dependencies: - install deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts script: - rsync -avz --delete dist/ "$SERVER_USER@$SERVER_HOST:$SERVER_PATH" rules: - if: '$CI_COMMIT_BRANCH == "main"' environment: name: production逐段拆开说。
image 字段:每个 job 都指定了一个基础镜像。node 镜像负责前端构建,deploy 阶段的 alpine 镜像体积小、起得快,只需要装 openssh-client 和 rsync 就能做文件传输。这样做的本质是“职责分离”:构建环境不需要部署工具,部署环境不需要 Node.js。
before_script:每个 job 都先打印 node 和 npm 版本,作用只有一个——排查问题的时候第一眼确认运行环境,这个习惯能省掉大量无谓的“环境不一致”争吵。
npm ci vs npm install:我强烈建议用npm ci,它严格按照 package-lock.json 安装,速度快、可复现,CI 环境里它就是比npm install稳。前者如果 lock 文件与 package.json 不匹配会直接报错,这反而是件好事,说明你的依赖声明有问题。
artifacts:这是 GitLab CI 里最容易被误解的概念之一。它不是“缓存”,而是 job 产出的“正式结果”。install job 把 node_modules 打包传给后续 job,build job 再把 dist 目录传给 deploy job。artifacts 默认会保留到流水线结束后,方便你下载查看,expire_in控制保留时间,node_modules 留 1 天就够,dist 留 7 天。部署这类敏感操作完成后,dist 的 artifacts 其实也可以删,但留着没坏处,回滚排查时能直接用。
deploy 阶段的 rules:这里我用了一个条件判断,只有 main 分支的提交才执行部署。这样测试环境的需求可以复制同样的配置,把分支名换成 dev 即可,后面章节会专门展开多环境。
3.3 部署脚本里的安全细节
deploy 阶段是整个配置中最“危险”的部分,因为它拿到了服务器的 SSH 权限。我见过太多人直接在 .gitlab-ci.yml 里写死服务器用户名密码,这是绝对要避免的做法。
三个安全细节值得强调。
第一,私钥不能直接埋在 yml 里,必须通过$SSH_PRIVATE_KEY引用项目变量。GitLab 的变量支持 Masked 属性,勾选后在日志里会被打码,但注意不是所有字符串都能被 mask(比如太短的字符串就不行),所以私钥这类长串文本没问题。
第二,写入私钥后必须立刻chmod 600,否则 SSH 会直接拒绝使用权限过宽的文件,并提示“Permissions 0644 for 'id_rsa' are too open”。这是 Cl 环境里很容易踩的坑,看着是权限问题,其实已经帮你挡了一次危险操作。
第三,rsync 用--delete参数时要谨慎。它保证了本地 dist 和服务器部署目录的完全一致,旧的构建产物会被清掉,这个特性非常有用,但如果路径配错(比如指到服务器上的根目录或另一个重要目录),会把你直接清懵。所以 SERVER_PATH 变量最好写成明确的绝对路径,比如/var/www/html/my-project,然后在部署完成后的第一步就验证目录内容。
另外提一个被很多人忽略的细节:ssh-keyscan会绕过首次连接的 host key 确认过程。这在 CI 环境里是必要的,因为非交互式 shell 无法手动输入 yes,但我建议把StrictHostKeyChecking保持默认 yes,而不是图省事写-o StrictHostKeyChecking=no。用ssh-keyscan -H提前写入 known_hosts 既安全又省事,不会让服务器在不知不觉中被中间人攻击。
4. 首跑流水线的典型报错:从现象到根因的排查记录
自动化部署第一次跑通之前,一定会遇到几个报错。这里我把最常见的四条完整排查过程记录下来,每条都从现象讲起,最后给出根因和解决方式。
4.1 IDE/CLI 登录失败:版本兼容的乌龙
第一个典型报错其实发生在写 pipeline 之前。我刚开始用 GitLab 时,在 IDEA 里配置完 GitLab 账号,一点登录就弹出一个提示:Login failed. GitLab versions older than 14.0 are not supported。我当时的直觉是 token 有问题,于是重新生成了令牌、反复检查 URL,全都无效。
冷静下来之后才发现方向错了。这个报错的关键信息不是“token 不正确”,而是“版本不满足要求”。新版 IDE 插件和 GitLab CLI 调用的都是 GitLab 新版 API,老版本 GitLab(低于 14.0)并没有提供这些 REST 接口,或者返回的数据结构和预期对不上,于是客户端索性拒绝登录。
解决方式有两条路可选:升级 GitLab,或者换用支持旧版的客户端。如果 GitLab 是你自己部署的,强烈建议升级,官方升级文档写得很清楚,注意低版本要按版本号逐步升级,不能直接跳到最新,否则数据库迁移会出问题;升级前一定要备份/etc/gitlab目录和数据库。
如果是公司里由别人运维的 GitLab,你暂时无权升级,那就先用 SSH 方式登录 IDE,或者改用 Git 命令行提交代码,绕开新版 API 的依赖。
4.2 Runner 注册成功却一直 Offline / Job 永远 Pending
第二个高频问题是我把 Runner 装好、注册成功之后,发现 GitLab 后台里 Runner 一直灰色,触发的 job 永远是 pending,不执行。
排查链路是这样的:先在 Runner 机器上执行gitlab-runner status和gitlab-runner verify,确认本地进程是活的;然后从 Runner 机器上curl -I http://gitlab.example.com:8080,确认能访问 GitLab 的 Web 服务;接着检查防火墙,如果 Runner 和 GitLab 之间隔了防火墙,必须放行对应的端口。
这一套走完,最常见的原因其实不在网络,而在tag 不匹配。job 里如果写了tags: [web, node],而 Runner 注册时只打了node标签,GitLab 就会认为没有可用的 Runner,job 一直 pending。解决方式很简单:要么把 job 里的 tags 去掉,让它跑在任何可用的 Runner 上;要么保证 Runner 的标签覆盖了 job 的要求。
这类问题最好通过看 Runner 日志来定位,命令是docker logs gitlab-runner或journalctl -u gitlab-runner -f,日志里会明确写出类似 “Job was not executed because it would use a masked runner” 之类的信息,比瞎猜快得多。
4.3 SSH 免密部署失败与 known_hosts 问题
第三个经典报错出现在 deploy 阶段,日志里最常见的两句话是Host key verification failed和Permission denied (publickey)。
先说第一句。服务器首次连接时会要求确认 host key,CI 环境是非交互式,这个确认过程会直接失败,于是部署就卡在 ssh 连接这一步。我的处理方式是在 before_script 里用ssh-keyscan -H "$SERVER_HOST"把服务器指纹写入 known_hosts。这样既绕开了交互确认,也保留了 host key 校验,属于正规做法而非绕过后门。
再说第二句。SSH 免密依赖私钥,但 CI 里的私钥往往是从本地 Windows 机器上复制过来的,格式可能是-----BEGIN RSA PRIVATE KEY-----的老版本格式,OpenSSH 的新版本默认不再接受。遇到这种报错,先在本地执行ssh-keygen -p -m PEM -f key_file转成 PEM 格式,或者干脆重新生成一对 OpenSSH 格式密钥,然后把公钥追加到服务器的~/.ssh/authorized_keys里。
这里有个细节:很多人在本地一直用的用户名和服务器上的用户名不一致,例如本地是admin,服务器上实际用户是deploy,导致连接时身份不对。排查时先手动在服务器上执行一次ssh-keygen -t ed25519 -C "gitlab-ci",并在 authorized_keys 里确认一下密钥内容,能省掉很多不必要的折腾。
4.4 分支保护导致的写权限冲突
最后一个报错发生在“想跑自动化发布,但流水线想做的事被 GitLab 拦住了”的场景。GitLab 默认对 main/master 分支启用了分支保护,Developer 角色的用户默认是无法直接 push 到受保护分支的——这其实是很多人问的“GitLab developer 可以提交代码到 master 吗”的标准答案:可以 merge request,但不能直接 push。
CI 里的 job 默认用的是当前用户的身份和权限。如果你的部署脚本里有“生成版本号、打 tag、推送到远程仓库”之类的操作,但当前用户是 Developer,就会被 403 拦下来。我以前在 build 阶段加了一步自动生成版本文件并推回仓库,第一次上线就遇到了这个报错。
解决方式有三种,按推荐程度排序。第一种是不要在 CI 里“写回”仓库,改为在构建产物里多放一个带时间戳和 commit hash 的文件,部署时服务器侧读取即可;第二种是去项目设置里调整受保护分支的允许角色,把Allowed to merge和Allowed to push设置放开到指定角色;第三种是给 CI 单独创建一个 Deploy Token 或 Project Access Token,并把它的权限控制在最小范围,只在需要写仓库的 job 里引用这个 token。
需要特别提醒:不要把这种权限冲突理解成“GitLab 故意添乱”。它本质上是在保护一个重要分支不被随意覆盖。正式的团队协作里,维护这个保护机制反而对发布安全有利。
5. 前端部署的进阶细节:多环境、缓存策略和上线后的小技巧
流水线能跑通,只是万里长征第一步。接下来这几个细节,是真正决定这套自动化部署能不能在日常团队协作里长期用下去的关键。
5.1 一套配置跑通 dev / staging / prod
多数前端团队有多个环境,如果每个环境都写一份 .gitlab-ci.yml,那就是给自己埋了一堆重复代码的坑。GitLab 支持同一份配置里通过rules区分不同分支、不同变量。
做法很简单:把不同环境的差异都收敛到变量上,流水线本身只有一份。
deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts script: - rsync -avz --delete dist/ "$SERVER_USER@$SERVER_HOST:$SERVER_PATH" rules: - if: '$CI_COMMIT_BRANCH == "dev"' variables: SERVER_HOST: "$DEV_SERVER_HOST" SERVER_USER: "$DEV_SERVER_USER" SERVER_PATH: "$DEV_SERVER_PATH" - if: '$CI_COMMIT_BRANCH == "main"' variables: SERVER_HOST: "$PROD_SERVER_HOST" SERVER_USER: "$PROD_SERVER_USER" SERVER_PATH: "$PROD_SERVER_PATH"很多新手看到variables里套变量会觉得没必要,直接写死不就行了?但一旦你的服务器 IP 换了,或者测试环境和生产环境分开管理,写死的结果就是去改流水线配置、触发一次不必要的新任务、还冒着改错影响生产部署的风险。用变量引用变量,环境相关的东西全部收敛到 GitLab 项目设置里,这才是可维护的做法。
同时,在 deploy job 里配上environment: name: production这类字段,GitLab 的“环境”页面就能自动记录每一次部署的时间和 commit,回滚时直接用 UI 都比查日志方便。
5.2 CI 缓存与构建速度的平衡
前端构建最耗时的就是npm install。为了省时间,我给 install job 设置了 cache:
cache: key: "$CI_COMMIT_REF_SLUG" paths: - node_modules/cache 专门缓存node_modules目录,key 按分支区分,避免不同分支之间相互污染。这里解释一下 cache 和 artifacts 的区别:cache 是“依赖的中间缓存”,可以被多次任务复用;artifacts 是“当前任务的正式产出”,传给下游任务。很多人把这两个概念混在一起,导致配置出了问题还很迷惑。用一句话总结:npm i 之后 node_modules 可以被缓存,build 之后 dist 应该被当作 artifacts 传递。
缓存也不是越大越好。node_modules里混进了临时文件、调试包,会让缓存的命中率变低,坏缓存还会引发各种诡异的报错。我的习惯是缓存只保留关键路径,同时给缓存设置一个合理的时间,不要让它无限期躺在 Runner 的存储里。
5.3 版本号与前端强制刷新
前端上线遇到的最尴尬问题之一,就是“新版已经发布了,但用户浏览器还在用旧版”。原因很多:SPA 的 index.html 被本地缓存、静态资源带 hash 但入口文件没过期、用户开着页面一直不刷新……被动等用户刷新不是办法,不如让页面自己“知道”该刷新了。
我在 build 阶段会在public目录下生成一个version.json,内容大概是这样的:
echo "{\"buildTime\":\"$(date +%Y-%m-%dT%H:%M:%S)\",\"commit\":\"$CI_COMMIT_SHORT_SHA\"}" > public/version.json前端在主应用启动时轮询这个文件,对比本地缓存的版本号,发现不一致就提示用户刷新页面。核心逻辑大概是:
let cachedVersion = null; async function checkVersion() { const res = await fetch('/version.json?t=' + Date.now()); const data = await res.json(); if (cachedVersion && data.commit !== cachedVersion) { // 提示刷新 window.location.reload(); } cachedVersion = data.commit; } setInterval(checkVersion, 60000);注意在 fetch 时加上t=Date.now()查询参数,避免浏览器对 version.json 本身产生缓存。配合 nginx 对 HTML 文件设置Cache-Control: no-cache,这个方案能有效减少“明明上线了,用户还是旧版”的投诉。它当然不是万能的,长连接场景下还是要靠更完善的推送机制,但对绝大多数普通 Web 应用来说,性价比已经很高了。这个思路很契合热搜词里提到的“通过版本号的变更,让前端强制刷新页面”,本质上是给前端装一个“版本感知器”。
5.4 发布后的回滚姿势
最后聊一个上线前就要想好的问题:回滚。CI/CD 并不是不会出问题,反而因为自动化程度高,一旦出问题影响面可能更大。一条错误的 nginx 配置、一个错误的构建开关,都可能让线上瞬间不可用。所以部署方案一定要把回滚设计进去,而不是出事了才想起“刚才那个版本在哪”。
我的做法是在服务器上保留最近几个历史版本的 dist 目录,按时间戳命名,比如releases/20261123153021/,然后用一个软链接current指向当前版本,nginx 配置直接指向current目录:
root /data/app/webapp/current;发布流程变成:rsync 新版本到新的时间戳目录 -> 更新current软链 -> reload nginx。一旦新版本出问题,只要把软链指回上一个版本目录、reload nginx,就完成回滚了,整个过程用不了十秒。这个思路比“重新从历史 pipeline 里下载旧 artifacts 再部署”要快得多,也更安全。
有的团队会嫌维护多个版本目录占磁盘,但前端构建产物一般就几 MB 到几十 MB,多留三五个版本完全可接受。磁盘在这时候是真便宜,换取的上线安全感是真贵。
这套自动化部署链路搭完,我最大的感受是:以前上线全靠默契和胆量,现在上线全靠流程和日志。最后一次调整完部署脚本,我在流水线日志里看到 deploy job 亮起绿钩的那一瞬间,心里那块石头才算真正落了地。最后再分享一个我坚持到现在的习惯——deploy 阶段脚本第一行永远是set -e,然后第二行 echo 出当前的 commit hash 和部署时间。别人看日志时能一眼知道服务器上跑的是哪次提交,这比任何文档都管用。