{"repo": "1024xengineer/trpg-master", "files": {".github/workflows/pr-preview.yml": {"dfc9a884366666f29dd42785bb4af2471f38fb2e": {"t": 1785745255.0909424, "n": 12113, "h": "e69b1fc84efcc24c", "c": "name: PR Preview\n\n# CI Preview(issue #200):PR 开/更新时自动构建 backend+frontend 镜像、部署到\n# 预览服务器、在 PR 上评论访问链接;PR 关闭时自动回收。\n#\n# 顶层不再用 paths 过滤(跟旧版的关键差异):paths 过滤器是基于\"这个事件\n# 触发时 PR 的完整 diff\"判断的——如果 PR 曾经改过 trpg-backend/** 触发了\n# 部署,后续又把这些改动全部撤销、最终 diff 只剩不在监视列表里的文件,\n# 后续 synchronize 甚至最终的 closed 事件都不会再匹配 paths,已部署的容器\n# 和目录会永久遗留、PR 评论也不会更新成\"已回收\"(fennoai review 抓到的\n# 真问题)。改成 workflow 本身总是对 opened/synchronize/reopened/closed\n# 触发,路径判断挪到 job 内部(见 changes job),只门控是否需要真的构建\n# 部署;teardown job 不受这个判断影响,只要事件是 closed 就一定跑。\non:\n pull_request:\n types: [opened, synchronize, reopened, closed]\n\n# 按 PR 号分组只能防止\"同一个 PR 的两次 push 互相打架\",防不了\"两个不同\n# PR 算出同一个端口槽位、同时部署互相抢占同一个宿主机端口\"(fennoai review\n# 抓到的真问题:docker compose 对同一个端口第二次 up 会直接报\n# \"port is already allocated\" 失败,不是\"后部署的把先部署的挤掉\")。改成\n# 全仓库级别的单一队列——预览部署这种操作频率本来就不高,排队执行换来\n# 正确性,比并发换来的那点速度更值。\nconcurrency:\n group: pr-preview-deploy-lock\n cancel-in-progress: false\n\njobs:\n changes:\n if: github.event.action != 'closed'\n runs-on: ubuntu-latest\n outputs:\n relevant: ${{ steps.filter.outputs.relevant }}\n steps:\n - uses: actions/checkout@v4\n - uses: dorny/paths-filter@v3\n id: filter\n with:\n filters: |\n relevant:\n - \"trpg-backend/**\"\n - \"trpg-frontend/**\"\n - \"trpg-sdk/**\"\n - \"agent-collaboration-framework/**\"\n - \"docker-compose.preview.yml\"\n - \".github/workflows/pr-preview.yml\"\n\n deploy:\n needs: changes\n # fork 来的 PR 触发 `pull_request` 时,GITHUB_TOKEN 平台级强制降级成只读\n # ——不管这里声明多少 permissions 都没用,push 镜像到 GHCR、在 PR 上评论\n # 这两步必然失败。与其让每个 fork PR 都红一次,不如直接跳过整个 job:\n # 见 issue #200「本期不做 · fork PR 的真实 AI 预览」,这里把范围收紧到\n # \"fork PR 完全不跑 Preview\",比\"跑起来但降级\"更准确地反映了 GitHub\n # 的实际限制。\n if: |\n github.event.action != 'closed' &&\n needs.changes.outputs.relevant == 'true' &&\n github.event.pull_request.head.repo.full_name == github.repository\n runs-on: ubuntu-latest\n permissions:\n contents: read\n packages: write\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n\n - name: 计算镜像 tag 与预览端口\n id: meta\n run: |\n PR=${{ github.event.pull_request.number }}\n OWNER_LOWER=$(echo \"${{ github.repository_owner }}\" | tr '[:upper:]' '[:lower:]')\n\n # 预览服务器是 PortForward 网关(没有独立公网 IP),不是想开多少\n # 端口就能开多少——只有网关那边显式配置过的端口才能从外部访问。\n # 主分支持久预览固定占用 80→10005,PR 预览不能复用这个入口。\n # 剩余显式配置了 4 对(内部端口 → 外部端口):\n # 12500→10496 5010→10497 5000→10498 9091→10499\n # 用 PR 号取模从这 4 对里选一对:内部端口是 docker-compose 里\n # frontend 服务实际绑定的宿主机端口,外部端口是评论里给出的访问\n # 链接。这意味着**同时最多 4 个 PR 能有预览**——超过 4 个同时开着\n # 的 PR 会撞进同一个槽位;\"部署到预览服务器\"那一步会显式检测这种\n # 冲突并直接失败(不是静默覆盖),见下面的说明。真需要更多并发\n # 槽位得找网关加映射。\n INTERNAL_PORTS=(12500 5010 5000 9091)\n EXTERNAL_PORTS=(10496 10497 10498 10499)\n SLOT=$((PR % 4))\n\n {\n echo \"backend_image=ghcr.io/$OWNER_LOWER/trpg-master-backend:pr-$PR\"\n echo \"frontend_image=ghcr.io/$OWNER_LOWER/trpg-master-frontend:pr-$PR\"\n # 同一个 PR 号在生命周期内槽位固定,重新部署(push 新 commit)\n # 不会换端口,评论里的链接不用改。\n echo \"internal_port=${INTERNAL_PORTS[$SLOT]}\"\n echo \"external_port=${EXTERNAL_PORTS[$SLOT]}\"\n } >> \"$GITHUB_OUTPUT\"\n\n - name: 登录 GHCR\n uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - uses: docker/setup-buildx-action@v3\n\n - name: 构建并推送 backend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-backend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.backend_image }}\n cache-from: type=gha,scope=preview-backend\n cache-to: type=gha,mode=max,scope=preview-backend\n\n - name: 构建并推送 frontend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-frontend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.frontend_image }}\n cache-from: type=gha,scope=preview-frontend\n cache-to: type=gha,mode=max,scope=preview-frontend\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n # 固定服务器 host key,不做运行时 ssh-keyscan:运行时 keyscan 拿到\n # 的公钥没有可信来源,网络中的攻击者可以在 runner 和预览服务器之间\n # 冒充服务器、拿到随后发送的 GHCR token 和 DeepSeek key(review\n # 抓到的真问题)。这里写的是我直接从服务器本机 `ssh-keyscan`\n # 出来、经过 SSH 连接亲自核实过指纹的三条 host key,作为 secret\n # 存着(不是密钥,只是拓扑信息,但同样不写进这个公开文件)。\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 部署到预览服务器\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n BACKEND_IMAGE: ${{ steps.meta.outputs.backend_image }}\n FRONTEND_IMAGE: ${{ steps.meta.outputs.frontend_image }}\n PORT: ${{ steps.meta.outputs.internal_port }}\n PR: ${{ github.event.pull_request.number }}\n GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n GHCR_ACTOR: ${{ github.actor }}\n # Preview 专用 key,跟本地/生产用的 key 分开管理,方便单独限额/吊销。\n DEEPSEEK_API_KEY: ${{ secrets.PREVIEW_DEEPSEEK_API_KEY }}\n run: |\n # 远程 provider 模式缺 key 时后端会启动失败(不是优雅降级,\n # README「主持模型配置」一节已说明)——这个判断必须在部署前做,\n # 不能让 compose 直接把空字符串当 key 传给 deepseek provider。\n if [ -n \"$DEEPSEEK_API_KEY\" ]; then\n HOST_MODEL_PROVIDER=deepseek\n else\n HOST_MODEL_PROVIDER=fake\n fi\n\n # 注意这里不再 scp 仓库里的 docker-compose.preview.yml——PR 分支\n # 能修改这个文件的内容,而部署账号在 docker 组里(等同宿主机\n # root)。如果直接执行 PR 自己带来的 compose 文件,任何有权限\n # 给这个仓库开同仓库 PR 的人都能在 compose 里加宿主机根目录/\n # docker.sock 挂载或 privileged 容器,从而拿到预览服务器的\n # root(review 抓到的真问题,五条里最严重的一条)。改成部署脚本\n # 只把镜像引用和端口当参数传给服务器上早就放好、PR 完全碰不到的\n # 固定 compose 模板(~/trpg-previews/compose-template/,这份文件\n # 只由有服务器访问权限的人手动维护,不经过 CI)——PR 能决定\"用\n # 哪个镜像\",决定不了\"以什么权限、挂载什么运行这个镜像\"。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <\n
\n
\n
\n