# 备用部署路线:**只能手动触发**(Actions 页面 → Deploy → Run workflow)。 # 平时的部署是 push origin 时由开发机上的 gitea-runner 跑 .gitea/workflows/deploy.yml, # 这份只在开发机不在、又急着上线时用。 # # 为什么不再 push 就跑:GitHub 的 runner 在海外,到服务器晚高峰只有十几 KB/s, # rsync 一次要五六分钟(后端改一行,--bytecode 那段约 6MB 字节码就整段重写,增量省不掉)。 # 而 origin 会镜像到 github,两边都 push 触发的话每次会部署两遍,两边的并发锁又不通。 # # 流程:在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。 # 产物不在服务器上编,是因为服务器性能差,镜像 builder 阶段(1238 个包的 bun install + # bun compile + vite build)首次约 5 分钟,光前端就 160s。 # # 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的 # 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖任何工作流。 # # job 上卡了 server_url:只在 GitHub 上跑。仓库有了 .gitea/workflows 之后 Gitea 本来就 # 不再读这份,这道闸留着防万一。 # # 仓库 Settings → Secrets 需要两个: # HOST 服务器地址 # KEY 能以 root 登录该服务器的私钥(完整 PEM,含首尾 BEGIN/END 行) name: Deploy on: # 手动触发时可以放行破坏性迁移;不勾就停在闸门上把工作流判红。 # 那种改动得有人先确认备份。 workflow_dispatch: inputs: allow_destructive: description: "放行破坏性迁移(DROP TABLE / DROP COLUMN / ALTER TYPE …)。勾之前先确认已备份数据库。" type: boolean default: false permissions: contents: read # 两次 push 挨得近时,别让两个 deploy.sh 在服务器上同时起栈。 concurrency: group: deploy cancel-in-progress: false env: REMOTE_PORT: 22 REMOTE_DIR: /root/OJDeploy/OJ2 jobs: deploy: if: github.server_url == 'https://github.com' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # 版本跟着 package.json 的 packageManager 走。单二进制会把运行时一起打进去, # 这里装的是哪个版本,线上跑的就是哪个版本 —— 不能是 latest。 - uses: oven-sh/setup-bun@v2 with: bun-version-file: package.json # 依赖装到 ~/.bun/install/cache,命中的话 install 只剩几秒。 # v6 起跑在 node24 上(v4 还是 node20,GitHub 正在退役 node20 runtime), # 和上面两个 action 一致。v5+ 要求 runner >= 2.327.1,ubuntu-latest 是 # GitHub 托管的、始终满足;换自建 runner 的话要留意这条。 - uses: actions/cache@v6 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: 装依赖 run: bun install --frozen-lockfile # 单二进制,--target=bun-linux-x64(runner 就是 linux-x64,和服务器一致)。 # 输出到 dist/oj2-api,正是 Dockerfile 里 artifacts-prebuilt 阶段要的位置。 - name: 编后端 run: bun run --filter '@oj2/api' build # 默认 production 模式,读 apps/web/.env.production(在版本库里)。 - name: 编前端 run: cd apps/web && bun run build # 格式、lint、前后端类型检查、路由遮蔽、AST 节点,有一项不过就不部署(同步到服务器之前)。 # 不加它的话,每次 push 都是直接上线、中间没有任何检查 —— vite build 不做类型检查。 # # 必须排在「编前端」之后:前端类型检查要 unplugin 生成的 auto-imports.d.ts / # components.d.ts,它们不进版本库,vite build 时才生成;排在前面会报几百条 # Cannot find name 'computed'(实测过)。 - name: 检查 run: bunx vp run verify - name: 配置 SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh printf '%s\n' "${{ secrets.KEY }}" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null # 源码和产物一起传。dist/oj2-api 有 115MB,但 rsync 对已存在的文件做增量, # 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。 # # 慢在线路上:runner 在海外,到服务器晚高峰只有十几 KB/s,传 3~4MB 就要五六分钟。 # 后端只要改了一行,--bytecode 那段字节码(约 6MB)就整段重写、增量也省不掉, # 它占了每次传输量的七成多。下面几个开关都是为了少传、传不动时别干等: # -c 按内容比,不看 mtime。checkout 出来每个文件的 mtime 都是「刚才」, # 不加它 rsync 会把 600 多个没改的源码逐个走一遍增量、来回交换校验块。 # zstd 19 比默认的 zlib 再少传约 17%(实测 4.09MB → 3.38MB),压缩只花 runner 的 CPU。 # --delay-updates 全部传完才一次性换上去,中途断了服务器上不会留下半新半旧的源码。 # --timeout 60 秒没有任何数据进出就算断了,交给外面的循环重试; # 重试时 --delay-updates 留在 .~tmp~ 里的半截文件会接着用,不从头传。 # ServerAlive 连接悄悄死掉时 ssh 自己能发现,不会一直挂着。 # --stats 日志里能看到这次到底传了多少字节。 # # --delete 的排除项少一个就会出事,逐条说明: # docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。 # 删了下一步 deploy.sh 自检就直接 die。 # data/ 测试点、题面图片、学生上传的文件,还有「自带数据」形态下的 # 整个 postgres 数据目录。删了就是删库。 # node_modules 服务器上根本不需要(prebuilt 形态下连编都不编),传过去纯浪费。 # .git checkout 出来的 .git 没有任何用处。 # # --no-owner --no-group 别删:`-a` 展开含 `-o -g`(保留属主/属组),而接收端是 # root,rsync 会把**源端的 uid 真写上去**——GitHub 托管 runner 的 runner 用户是 # uid 1001,于是服务器上的工作区目录变成 1001 所有。而 `.git` 被 --exclude 掉, # 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被 # 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。 - name: 同步到服务器 timeout-minutes: 20 run: | for attempt in 1 2 3; do rsync -a -c --no-owner --no-group --delete-delay --delay-updates \ --compress --compress-choice=zstd --compress-level=19 \ --timeout=60 --stats --human-readable \ -e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \ --exclude node_modules \ --exclude .git \ --exclude data \ --exclude 'docker/.env*' \ ./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" && exit 0 echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试" sleep 10 done exit 1 # deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出, # 这一步跟着红,日志里能看到具体是哪一条。 # # 迁移在 deploy.sh 里排在起栈之前:非破坏性的(加索引、加列)自动执行; # 破坏性的会被拦下并退出 4,除非手动触发时勾了 allow_destructive。 # 走 env: 传而不是把 ${{ }} 直接拼进命令串 —— 值虽然是 GitHub 约束过的布尔, # 但拼字符串进 shell 的写法不该在这个仓库里留下先例。 # # ⚠️ 必须用 `inputs.`,**不能换成 `github.event.inputs.`**。后者把布尔 # 字符串化,没勾选时拿到的是字符串 "false" —— 那是个非空字符串,在 # `&&` 里为真,等于每次手动部署都无条件放行破坏性迁移。 - name: 起栈 env: ALLOW_DESTRUCTIVE: ${{ inputs.allow_destructive && '1' || '' }} run: | ssh -i ~/.ssh/id_deploy -p "$REMOTE_PORT" "root@${{ secrets.HOST }}" \ "cd $REMOTE_DIR && OJ2_ALLOW_DESTRUCTIVE='$ALLOW_DESTRUCTIVE' docker/deploy.sh --prebuilt"