Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/1024xengineer_trpg-master.json
T

1 line
44 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}