1 line
44 KiB
JSON
1 line
44 KiB
JSON
{"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 <<EOF\n set -e\n mkdir -p ~/trpg-previews/pr-$PR\n\n # 端口槽位冲突检测:这个内部端口如果已经被别的 PR 的容器占着,\n # docker compose up 只会报 \"port is already allocated\" 失败,\n # 不会像旧注释说的那样\"后部署的把先部署的挤掉\"(review 抓到的\n # 真问题)。这里显式查出占用者是谁,是自己就放行(重新部署),\n # 是别人就直接失败并给出清楚的原因,不静默、也不驱逐别人的预览。\n OCCUPANT=\\$(docker ps --format '{{.Names}}\\t{{.Ports}}' | grep \":$PORT->\" | awk '{print \\$1}' | head -1 || true)\n if [ -n \"\\$OCCUPANT\" ]; then\n OCCUPANT_PR=\\$(echo \"\\$OCCUPANT\" | sed -E 's/^pr-([0-9]+)-.*/\\1/')\n if [ \"\\$OCCUPANT_PR\" != \"$PR\" ]; then\n echo \"端口槽位(内部 $PORT)当前被 PR #\\$OCCUPANT_PR 的预览占用,本次 PR #$PR 部署中止。\" >&2\n echo \"预览服务器目前只有 4 个 PR 槽位(另有 1 个主分支持久预览槽位),同时开着的 PR 超过 4 个时会撞车——等 PR #\\$OCCUPANT_PR 关闭回收后再重试,或者手动清理它的预览。\" >&2\n exit 1\n fi\n fi\n\n cp ~/trpg-previews/compose-template/docker-compose.yml ~/trpg-previews/pr-$PR/docker-compose.yml\n cd ~/trpg-previews/pr-$PR\n echo \"$GHCR_TOKEN\" | docker login ghcr.io -u \"$GHCR_ACTOR\" --password-stdin\n export BACKEND_IMAGE=\"$BACKEND_IMAGE\"\n export FRONTEND_IMAGE=\"$FRONTEND_IMAGE\"\n export PREVIEW_PORT=\"$PORT\"\n export HOST_MODEL_PROVIDER=\"$HOST_MODEL_PROVIDER\"\n export DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\"\n docker compose -p pr-$PR pull\n # --wait:等 healthcheck 真的通过再算这一步成功,镜像坏了/\n # entrypoint 启动即崩溃会让这里失败,而不是\"容器建起来了\"就\n # 当作部署成功、去发一条骗人的\"已部署\"评论(review 抓到的真\n # 问题)。healthcheck 定义在 compose 模板里。\n docker compose -p pr-$PR up -d --force-recreate --remove-orphans --wait --wait-timeout 90\n EOF\n\n - name: 在 PR 上评论预览链接\n uses: actions/github-script@v7\n env:\n # 评论里给用户点的必须是外部端口(网关对外映射的那个),不是\n # docker-compose 实际绑定的内部端口——这两个数字不一样,见上面\n # \"计算镜像 tag 与预览端口\" 步骤的说明。上一步用 --wait 确认过\n # 健康检查通过才会走到这里,失败时 GitHub Actions 默认跳过后续\n # 步骤,不会执行到这条评论。\n EXTERNAL_PORT: ${{ steps.meta.outputs.external_port }}\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const url = `http://${process.env.SSH_HOST}:${process.env.EXTERNAL_PORT}`;\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已部署:${url}\\n\\n> 未配置 Preview 专用 DeepSeek key 时,守秘人回复会是确定性占位文案,不影响其余功能验证。\\n\\n_最新一次 push 会自动更新这个预览,不需要重新打开链接。_`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n body,\n });\n }\n\n teardown:\n # 不依赖 changes job(那个 job 对 closed 事件根本不跑),也不看\n # paths——只要是 closed 事件就一定尝试回收,见文件顶部的说明。\n if: github.event.action == 'closed'\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - name: 计算槽位归属(仅用于按 PR 号定位目录,不涉及部署)\n id: meta\n run: |\n echo \"pr=${{ github.event.pull_request.number }}\" >> \"$GITHUB_OUTPUT\"\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\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 PR: ${{ steps.meta.outputs.pr }}\n run: |\n # 同上:故意不加引号,$PR 要在 runner 这一侧展开(这里没有\n # secret,纯粹是为了跟部署脚本保持同一套写法)。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n if [ -d ~/trpg-previews/pr-$PR ]; then\n cd ~/trpg-previews/pr-$PR\n docker compose -p pr-$PR down -v --remove-orphans || true\n cd ~\n rm -rf ~/trpg-previews/pr-$PR\n fi\n EOF\n\n - name: 在 PR 上更新评论为已回收\n uses: actions/github-script@v7\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已回收。`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n }\n"}, "8f03d286b361aa308113c8dbbb88371d9b1f7b2d": {"t": 1785749299.2768552, "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 <<EOF\n set -e\n mkdir -p ~/trpg-previews/pr-$PR\n\n # 端口槽位冲突检测:这个内部端口如果已经被别的 PR 的容器占着,\n # docker compose up 只会报 \"port is already allocated\" 失败,\n # 不会像旧注释说的那样\"后部署的把先部署的挤掉\"(review 抓到的\n # 真问题)。这里显式查出占用者是谁,是自己就放行(重新部署),\n # 是别人就直接失败并给出清楚的原因,不静默、也不驱逐别人的预览。\n OCCUPANT=\\$(docker ps --format '{{.Names}}\\t{{.Ports}}' | grep \":$PORT->\" | awk '{print \\$1}' | head -1 || true)\n if [ -n \"\\$OCCUPANT\" ]; then\n OCCUPANT_PR=\\$(echo \"\\$OCCUPANT\" | sed -E 's/^pr-([0-9]+)-.*/\\1/')\n if [ \"\\$OCCUPANT_PR\" != \"$PR\" ]; then\n echo \"端口槽位(内部 $PORT)当前被 PR #\\$OCCUPANT_PR 的预览占用,本次 PR #$PR 部署中止。\" >&2\n echo \"预览服务器目前只有 4 个 PR 槽位(另有 1 个主分支持久预览槽位),同时开着的 PR 超过 4 个时会撞车——等 PR #\\$OCCUPANT_PR 关闭回收后再重试,或者手动清理它的预览。\" >&2\n exit 1\n fi\n fi\n\n cp ~/trpg-previews/compose-template/docker-compose.yml ~/trpg-previews/pr-$PR/docker-compose.yml\n cd ~/trpg-previews/pr-$PR\n echo \"$GHCR_TOKEN\" | docker login ghcr.io -u \"$GHCR_ACTOR\" --password-stdin\n export BACKEND_IMAGE=\"$BACKEND_IMAGE\"\n export FRONTEND_IMAGE=\"$FRONTEND_IMAGE\"\n export PREVIEW_PORT=\"$PORT\"\n export HOST_MODEL_PROVIDER=\"$HOST_MODEL_PROVIDER\"\n export DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\"\n docker compose -p pr-$PR pull\n # --wait:等 healthcheck 真的通过再算这一步成功,镜像坏了/\n # entrypoint 启动即崩溃会让这里失败,而不是\"容器建起来了\"就\n # 当作部署成功、去发一条骗人的\"已部署\"评论(review 抓到的真\n # 问题)。healthcheck 定义在 compose 模板里。\n docker compose -p pr-$PR up -d --force-recreate --remove-orphans --wait --wait-timeout 90\n EOF\n\n - name: 在 PR 上评论预览链接\n uses: actions/github-script@v7\n env:\n # 评论里给用户点的必须是外部端口(网关对外映射的那个),不是\n # docker-compose 实际绑定的内部端口——这两个数字不一样,见上面\n # \"计算镜像 tag 与预览端口\" 步骤的说明。上一步用 --wait 确认过\n # 健康检查通过才会走到这里,失败时 GitHub Actions 默认跳过后续\n # 步骤,不会执行到这条评论。\n EXTERNAL_PORT: ${{ steps.meta.outputs.external_port }}\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const url = `http://${process.env.SSH_HOST}:${process.env.EXTERNAL_PORT}`;\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已部署:${url}\\n\\n> 未配置 Preview 专用 DeepSeek key 时,守秘人回复会是确定性占位文案,不影响其余功能验证。\\n\\n_最新一次 push 会自动更新这个预览,不需要重新打开链接。_`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n body,\n });\n }\n\n teardown:\n # 不依赖 changes job(那个 job 对 closed 事件根本不跑),也不看\n # paths——只要是 closed 事件就一定尝试回收,见文件顶部的说明。\n if: github.event.action == 'closed'\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - name: 计算槽位归属(仅用于按 PR 号定位目录,不涉及部署)\n id: meta\n run: |\n echo \"pr=${{ github.event.pull_request.number }}\" >> \"$GITHUB_OUTPUT\"\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\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 PR: ${{ steps.meta.outputs.pr }}\n run: |\n # 同上:故意不加引号,$PR 要在 runner 这一侧展开(这里没有\n # secret,纯粹是为了跟部署脚本保持同一套写法)。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n if [ -d ~/trpg-previews/pr-$PR ]; then\n cd ~/trpg-previews/pr-$PR\n docker compose -p pr-$PR down -v --remove-orphans || true\n cd ~\n rm -rf ~/trpg-previews/pr-$PR\n fi\n EOF\n\n - name: 在 PR 上更新评论为已回收\n uses: actions/github-script@v7\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已回收。`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n }\n"}}, "docker-compose.preview.yml": {"dfc9a884366666f29dd42785bb4af2471f38fb2e": {"t": 1785746354.843201, "n": 2870, "h": "a95c6c480d183d1c", "c": "# CI Preview 用(issue #200)。一份文件两种用法:\n#\n# 本地直接复现(构建上下文是仓库根目录里的源码):\n# docker compose -f docker-compose.preview.yml up --build\n#\n# 服务器上部署(workflow 在部署前已经把镜像 build 好推到 GHCR,只 pull\n# 预置好的 BACKEND_IMAGE/FRONTEND_IMAGE,不在服务器上跑 build——见\n# issue #200「决策 2」,不占用生产服务器的算力编译):\n# BACKEND_IMAGE=ghcr.io/.../trpg-master-backend:pr-123 \\\n# FRONTEND_IMAGE=ghcr.io/.../trpg-master-frontend:pr-123 \\\n# docker compose -f docker-compose.preview.yml pull\n# docker compose -f docker-compose.preview.yml up -d\n#\n# `image:` 和 `build:` 在同一个 service 里可以共存:`up --build` 用\n# build 产物打上 `image:` 里那个 tag;不传 --build 时 `pull`/`up` 只认\n# `image:`,不会碰 build 那部分——这就是两种用法不用拆两份文件的原因。\n#\n# 数据库故意用容器内 SQLite(不挂宿主机 volume):预览环境的数据没有留存\n# 价值,容器销毁即丢,完全不碰生产/开发用的 Postgres——见 issue #200\n# 「决策 5」。\n#\n# HOST_MODEL_PROVIDER / DEEPSEEK_API_KEY 从宿主环境变量透传:同仓库 PR 由\n# workflow 注入真实 key;fork PR 因 GitHub Actions 平台限制拿不到 secrets、\n# 甚至连这个 workflow 的部署 job 都不会跑(见 pr-preview.yml 里的 job\n# 条件),本文件里的 fallback 只覆盖\"本地跑、不传任何 key\"这一种情况——\n# 远程 provider 模式缺 key 时后端会启动失败,不是优雅降级,所以本地不传\n# key 时必须落到 fake,不能让 HOST_MODEL_PROVIDER 意外等于 deepseek。\n\nservices:\n backend:\n image: ${BACKEND_IMAGE:-trpg-backend-preview:local}\n build:\n context: .\n dockerfile: trpg-backend/Dockerfile\n environment:\n APP_ENV: production\n DATABASE_URL: sqlite+aiosqlite:///./app.db\n ENABLE_DOCS: \"true\"\n LOG_LEVEL: INFO\n # 前端是同源反代(Caddy 转发 /api、/ws 到这个服务),浏览器请求走的是\n # 前端自己的 origin,不是这个容器的地址——理论上不需要 CORS;保留一个\n # 宽松值只是兜底(比如直接拿后端端口调试),不依赖它生效。\n CORS_ORIGINS: '[\"*\"]'\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n # 开场超时独立于 Provider HTTP 超时,便于预览环境快速降级到安全模板。\n OPENING_NARRATION_MODE: ${OPENING_NARRATION_MODE:-model}\n OPENING_NARRATION_TIMEOUT_SECONDS: ${OPENING_NARRATION_TIMEOUT_SECONDS:-10}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}\n expose:\n - \"8000\"\n # `docker compose up -d` 只保证容器\"被创建/启动\",不代表 entrypoint 里\n # 的迁移/种子加载/uvicorn 真的跑成功了——镜像坏了或启动即崩溃,之前的\n # 写法照样会在几秒后把预览评论成\"已部署\"(review 抓到的真问题)。\n # python:3.13-slim 镜像没有 curl/wget,用内置 urllib 探活,不用额外装包。\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=3)\"]\n interval: 3s\n timeout: 3s\n retries: 20\n start_period: 5s\n\n frontend:\n image: ${FRONTEND_IMAGE:-trpg-frontend-preview:local}\n build:\n context: .\n dockerfile: trpg-frontend/Dockerfile\n depends_on:\n backend:\n condition: service_healthy\n ports:\n - \"${PREVIEW_PORT:-8080}:80\"\n healthcheck:\n test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:80/\"]\n interval: 3s\n timeout: 3s\n retries: 10\n start_period: 3s\n"}, "5857686f77ee58674c856e7c71c3f149721021af": {"t": 1785749299.9740286, "n": 2870, "h": "a95c6c480d183d1c", "c": "# CI Preview 用(issue #200)。一份文件两种用法:\n#\n# 本地直接复现(构建上下文是仓库根目录里的源码):\n# docker compose -f docker-compose.preview.yml up --build\n#\n# 服务器上部署(workflow 在部署前已经把镜像 build 好推到 GHCR,只 pull\n# 预置好的 BACKEND_IMAGE/FRONTEND_IMAGE,不在服务器上跑 build——见\n# issue #200「决策 2」,不占用生产服务器的算力编译):\n# BACKEND_IMAGE=ghcr.io/.../trpg-master-backend:pr-123 \\\n# FRONTEND_IMAGE=ghcr.io/.../trpg-master-frontend:pr-123 \\\n# docker compose -f docker-compose.preview.yml pull\n# docker compose -f docker-compose.preview.yml up -d\n#\n# `image:` 和 `build:` 在同一个 service 里可以共存:`up --build` 用\n# build 产物打上 `image:` 里那个 tag;不传 --build 时 `pull`/`up` 只认\n# `image:`,不会碰 build 那部分——这就是两种用法不用拆两份文件的原因。\n#\n# 数据库故意用容器内 SQLite(不挂宿主机 volume):预览环境的数据没有留存\n# 价值,容器销毁即丢,完全不碰生产/开发用的 Postgres——见 issue #200\n# 「决策 5」。\n#\n# HOST_MODEL_PROVIDER / DEEPSEEK_API_KEY 从宿主环境变量透传:同仓库 PR 由\n# workflow 注入真实 key;fork PR 因 GitHub Actions 平台限制拿不到 secrets、\n# 甚至连这个 workflow 的部署 job 都不会跑(见 pr-preview.yml 里的 job\n# 条件),本文件里的 fallback 只覆盖\"本地跑、不传任何 key\"这一种情况——\n# 远程 provider 模式缺 key 时后端会启动失败,不是优雅降级,所以本地不传\n# key 时必须落到 fake,不能让 HOST_MODEL_PROVIDER 意外等于 deepseek。\n\nservices:\n backend:\n image: ${BACKEND_IMAGE:-trpg-backend-preview:local}\n build:\n context: .\n dockerfile: trpg-backend/Dockerfile\n environment:\n APP_ENV: production\n DATABASE_URL: sqlite+aiosqlite:///./app.db\n ENABLE_DOCS: \"true\"\n LOG_LEVEL: INFO\n # 前端是同源反代(Caddy 转发 /api、/ws 到这个服务),浏览器请求走的是\n # 前端自己的 origin,不是这个容器的地址——理论上不需要 CORS;保留一个\n # 宽松值只是兜底(比如直接拿后端端口调试),不依赖它生效。\n CORS_ORIGINS: '[\"*\"]'\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n # 开场超时独立于 Provider HTTP 超时,便于预览环境快速降级到安全模板。\n OPENING_NARRATION_MODE: ${OPENING_NARRATION_MODE:-model}\n OPENING_NARRATION_TIMEOUT_SECONDS: ${OPENING_NARRATION_TIMEOUT_SECONDS:-10}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}\n expose:\n - \"8000\"\n # `docker compose up -d` 只保证容器\"被创建/启动\",不代表 entrypoint 里\n # 的迁移/种子加载/uvicorn 真的跑成功了——镜像坏了或启动即崩溃,之前的\n # 写法照样会在几秒后把预览评论成\"已部署\"(review 抓到的真问题)。\n # python:3.13-slim 镜像没有 curl/wget,用内置 urllib 探活,不用额外装包。\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=3)\"]\n interval: 3s\n timeout: 3s\n retries: 20\n start_period: 5s\n\n frontend:\n image: ${FRONTEND_IMAGE:-trpg-frontend-preview:local}\n build:\n context: .\n dockerfile: trpg-frontend/Dockerfile\n depends_on:\n backend:\n condition: service_healthy\n ports:\n - \"${PREVIEW_PORT:-8080}:80\"\n healthcheck:\n test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:80/\"]\n interval: 3s\n timeout: 3s\n retries: 10\n start_period: 3s\n"}, "8f03d286b361aa308113c8dbbb88371d9b1f7b2d": {"t": 1785749301.0436065, "n": 2675, "h": "a94d4fab048673a9", "c": "# CI Preview 用(issue #200)。一份文件两种用法:\n#\n# 本地直接复现(构建上下文是仓库根目录里的源码):\n# docker compose -f docker-compose.preview.yml up --build\n#\n# 服务器上部署(workflow 在部署前已经把镜像 build 好推到 GHCR,只 pull\n# 预置好的 BACKEND_IMAGE/FRONTEND_IMAGE,不在服务器上跑 build——见\n# issue #200「决策 2」,不占用生产服务器的算力编译):\n# BACKEND_IMAGE=ghcr.io/.../trpg-master-backend:pr-123 \\\n# FRONTEND_IMAGE=ghcr.io/.../trpg-master-frontend:pr-123 \\\n# docker compose -f docker-compose.preview.yml pull\n# docker compose -f docker-compose.preview.yml up -d\n#\n# `image:` 和 `build:` 在同一个 service 里可以共存:`up --build` 用\n# build 产物打上 `image:` 里那个 tag;不传 --build 时 `pull`/`up` 只认\n# `image:`,不会碰 build 那部分——这就是两种用法不用拆两份文件的原因。\n#\n# 数据库故意用容器内 SQLite(不挂宿主机 volume):预览环境的数据没有留存\n# 价值,容器销毁即丢,完全不碰生产/开发用的 Postgres——见 issue #200\n# 「决策 5」。\n#\n# HOST_MODEL_PROVIDER / DEEPSEEK_API_KEY 从宿主环境变量透传:同仓库 PR 由\n# workflow 注入真实 key;fork PR 因 GitHub Actions 平台限制拿不到 secrets、\n# 甚至连这个 workflow 的部署 job 都不会跑(见 pr-preview.yml 里的 job\n# 条件),本文件里的 fallback 只覆盖\"本地跑、不传任何 key\"这一种情况——\n# 远程 provider 模式缺 key 时后端会启动失败,不是优雅降级,所以本地不传\n# key 时必须落到 fake,不能让 HOST_MODEL_PROVIDER 意外等于 deepseek。\n\nservices:\n backend:\n image: ${BACKEND_IMAGE:-trpg-backend-preview:local}\n build:\n context: .\n dockerfile: trpg-backend/Dockerfile\n environment:\n APP_ENV: production\n DATABASE_URL: sqlite+aiosqlite:///./app.db\n ENABLE_DOCS: \"true\"\n LOG_LEVEL: INFO\n # 前端是同源反代(Caddy 转发 /api、/ws 到这个服务),浏览器请求走的是\n # 前端自己的 origin,不是这个容器的地址——理论上不需要 CORS;保留一个\n # 宽松值只是兜底(比如直接拿后端端口调试),不依赖它生效。\n CORS_ORIGINS: '[\"*\"]'\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}\n expose:\n - \"8000\"\n # `docker compose up -d` 只保证容器\"被创建/启动\",不代表 entrypoint 里\n # 的迁移/种子加载/uvicorn 真的跑成功了——镜像坏了或启动即崩溃,之前的\n # 写法照样会在几秒后把预览评论成\"已部署\"(review 抓到的真问题)。\n # python:3.13-slim 镜像没有 curl/wget,用内置 urllib 探活,不用额外装包。\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=3)\"]\n interval: 3s\n timeout: 3s\n retries: 20\n start_period: 5s\n\n frontend:\n image: ${FRONTEND_IMAGE:-trpg-frontend-preview:local}\n build:\n context: .\n dockerfile: trpg-frontend/Dockerfile\n depends_on:\n backend:\n condition: service_healthy\n ports:\n - \"${PREVIEW_PORT:-8080}:80\"\n healthcheck:\n test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:80/\"]\n interval: 3s\n timeout: 3s\n retries: 10\n start_period: 3s\n"}}}} |