From 23a625b5a910e12d5c7ecffcae138219199fd9e7 Mon Sep 17 00:00:00 2001 From: jettwang Date: Mon, 21 Sep 2026 13:31:01 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20main=20=E4=B8=8A=E7=9A=84=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E8=87=AA=E5=8A=A8=E8=B7=91=20bun=20test=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=88=B0=20cplus.talkincode.net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 站点曾经停在 4 节课,而仓库已经补到 40 节课——构建与部署只在某台机器上手工跑过一次, 之后没人重跑。这条链路现在由 CI 接手:main 上的推送先跑 bun test,通过后 bun run deploy 发布,最后抽检线上(每一节课、末尾斜杠跳转、词条面板、中文 404),任一步失败都会变红。 - .github/workflows/test-and-deploy.yml:push(main) / pull_request / workflow_dispatch; PR 上只跑测试,发布只在 main 上发生;同一时间只允许一次发布 - 凭据只从仓库 secret CLOUDFLARE_API_TOKEN 取;缺它时发布步骤明确失败, 不会静默跳过、把「没发布」伪装成绿色(账号 ID 不是凭据,写在仓库里) - tests/ci-workflow.test.ts:锁住触发条件、测试先于发布、发布只走 bun run deploy、 凭据来源、发布后的线上抽检覆盖 lessons/ 下每一节课,以及文档写清了 secret 名字 - README、docs/roadmap.md、docs/feature-checklist.md、AGENTS.md:把「发布是人工动作」 改写成「main 自动发布,人工只是兜底」 --- .github/workflows/test-and-deploy.yml | 117 ++++++++++++++++++++++++++ AGENTS.md | 2 +- README.md | 2 +- docs/feature-checklist.md | 2 +- docs/roadmap.md | 8 +- tests/ci-workflow.test.ts | 86 +++++++++++++++++++ 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test-and-deploy.yml create mode 100644 tests/ci-workflow.test.ts diff --git a/.github/workflows/test-and-deploy.yml b/.github/workflows/test-and-deploy.yml new file mode 100644 index 0000000..f2bcbae --- /dev/null +++ b/.github/workflows/test-and-deploy.yml @@ -0,0 +1,117 @@ +name: 测试与发布 + +# main 上的每次推送都先跑 bun test,通过后由 GitHub Actions 发布到 cplus.talkincode.net。 +# 这条链路存在的理由:站点曾经停在 4 节课,而仓库已经补到 40 节课——构建与部署只在某台 +# 机器上手工跑过一次,之后没人重跑。发布用仓库 secret CLOUDFLARE_API_TOKEN(Workers +# Scripts: Edit 权限);缺这个 secret 时 deploy 会明确失败,不会静默跳过、把「没发布」 +# 伪装成绿色。 +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + name: bun test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun test + + deploy: + name: 发布到 cplus.talkincode.net + needs: test + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + # 同一时间只允许一次发布;进行中的发布不打断,后续排队 + concurrency: + group: deploy-cplus-talkincode-net + cancel-in-progress: false + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + # 账号 ID 不是凭据,写在这里让发布只需要配一个 secret + CLOUDFLARE_ACCOUNT_ID: 83b40c9065a6f4631f4ab6cda824a21a + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - name: 检查部署凭据 + run: | + if [ -z "${CLOUDFLARE_API_TOKEN}" ]; then + echo "::error::缺少仓库 secret CLOUDFLARE_API_TOKEN,本次没有发布。请在 Settings → Secrets and variables → Actions 里添加一个带 Workers Scripts: Edit 权限的 Cloudflare API token,然后重新运行本工作流。" + exit 1 + fi + - name: 构建并发布 + run: bun run deploy + - name: 线上抽检 + run: | + set -u + set +e + origin=https://cplus.talkincode.net + + smoke() { + local fail=0 code body first + + # 每一节课都要能在线上打开:漏课正是这条链路要防的事故 + for dir in lessons/*/; do + id=$(basename "${dir}") + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "${origin}/lessons/${id}/") + if [ "${code}" != "200" ]; then + echo "::error::${id} 在线上返回 ${code}" + fail=1 + fi + done + + # 不带末尾斜杠的课程地址由静态资源层跳到带斜杠的地址 + first=$(ls -d lessons/*/ | head -1 | xargs basename) + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "${origin}/lessons/${first}") + if [ "${code}" != "307" ]; then + echo "::error::/lessons/${first} 期望 307 跳转,实际 ${code}" + fail=1 + fi + + # 词条面板是 40 节课共用的脚本,必须能取到 + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "${origin}/glossary/faq-panel.js") + if [ "${code}" != "200" ]; then + echo "::error::/glossary/faq-panel.js 期望 200,实际 ${code}" + fail=1 + fi + + # 找不到的地址给中文 404,而不是托管商的英文默认页 + body=$(curl -s -w "\n%{http_code}" --max-time 20 "${origin}/lessons/not-a-lesson/") + code=$(printf '%s' "${body}" | tail -1) + if [ "${code}" != "404" ] || ! printf '%s' "${body}" | grep -q "页面未找到"; then + echo "::error::不存在的地址期望中文 404,实际 ${code}" + fail=1 + fi + + return "${fail}" + } + + # 刚发布的版本可能还有几秒传播延迟,抽检失败时重试,连续三次才判定发布失败 + attempt=0 + while true; do + attempt=$((attempt + 1)) + if smoke; then + echo "线上抽检通过:每一节课、末尾斜杠跳转、词条面板、中文 404 都符合预期。" + exit 0 + fi + if [ "${attempt}" -ge 3 ]; then + echo "::error::线上抽检连续 ${attempt} 次未通过,本次发布需要人工检查。" + exit 1 + fi + echo "第 ${attempt} 次抽检未通过,10 秒后重试。" + sleep 10 + done diff --git a/AGENTS.md b/AGENTS.md index ea44bfa..a5ae763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## 交互与可维护性 - 开发服务器、测试、校验与脚手架一律使用 Bun;不得新增 `node`、`npm`、`npx`、`yarn` 或 Node 运行脚本。常用命令为 `bun run dev`、`bun test`、`bun run scaffold:lessons` 和 `bun run build`。 -- 静态站点只允许用 `bun run deploy` 发布(它内部先跑 `bun run build` 生成 `dist/`);不得手工拼装 `dist/` 或发布未提交的内容,线上内容必须等于仓库的构建结果,发布前先跑 `bun test`。 +- 静态站点只允许用 `bun run deploy` 发布(它内部先跑 `bun run build` 生成 `dist/`);main 上的推送由 `.github/workflows/test-and-deploy.yml` 自动跑「`bun test` → 发布 → 线上抽检」,人工发布只是兜底。不得手工拼装 `dist/` 或发布未提交的内容,线上内容必须等于仓库的构建结果。 - 浏览器中的课件可以保持无框架静态页面,但所有本仓库的开发工具必须能由 Bun 直接运行。 - 每节课必须位于 `lessons/<小写课程-id>/` 目录,例如 `lessons/s1-01/`;目录中的 `lesson.json` 是该课的交付状态与边界记录。 - 课程数据以 `courseData` 为单一内容源。每节课必须有唯一 `id`、有效 `phase`、至少三项目标、三项内容和三项检验项。 diff --git a/README.md b/README.md index a41f220..99c3dbd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ bun run deploy # 先构建,再用 bunx wrangler deploy 发布 dist/ 产物只能来自仓库内容:`bun run build` 每次都会清空并重建 `dist/`,`bun run deploy` 也会先跑一遍构建,所以线上不会出现「仓库里已经有这节课、站点上还是 404」的漂移。这个漂移真实发生过:站点一度停在 4 节课,而仓库已经补到 40 节课,原因是构建与部署只在某台机器上手工跑过一次、此后再没人重跑。 -发布前先跑 `bun test`。其中 [`tests/deploy-assets.test.ts`](./tests/deploy-assets.test.ts) 守住这条链路:构建产物覆盖 `courseData` 里的每一节课(缺课、`dist/` 里残留上一次的旧页面都会失败)、课程地址的末尾斜杠跳转(静态资源的 `html_handling`)与 Worker 的中文 404 仍然在,以及部署目标(Worker 名、`dist` 目录、自定义域名)与文档写的是同一个。部署需要本机有 Cloudflare 凭据(`bunx wrangler login`);仓库目前没有 CI,发布是人工动作,出错可用 `bunx wrangler rollback` 退回上一个版本。 +发布前先跑 `bun test`。其中 [`tests/deploy-assets.test.ts`](./tests/deploy-assets.test.ts) 守住这条链路:构建产物覆盖 `courseData` 里的每一节课(缺课、`dist/` 里残留上一次的旧页面都会失败)、课程地址的末尾斜杠跳转(静态资源的 `html_handling`)与 Worker 的中文 404 仍然在,以及部署目标(Worker 名、`dist` 目录、自定义域名)与文档写的是同一个。发布默认是自动的:main 上的每次推送都由 [`.github/workflows/test-and-deploy.yml`](./.github/workflows/test-and-deploy.yml) 接手——先跑 `bun test`,通过后 `bun run deploy` 发布,最后抽检线上(每一节课、末尾斜杠跳转、词条面板、中文 404),任一步失败都会让这次运行变红。它需要仓库 secret `CLOUDFLARE_API_TOKEN`(权限 `Workers Scripts: Edit`;账号 ID 不是凭据,已经写在仓库里),缺这个 secret 时发布步骤会明确失败、不会静默跳过。手动发布仍然可用:在有 Cloudflare 凭据的机器上 `bunx wrangler login` 之后 `bun run deploy`,出错用 `bunx wrangler rollback` 退回上一个版本。 发布后可以直接拿同一套浏览器复验打线上——`CSP_E2E_ORIGIN` 会复用已在运行的服务、不再自启开发服务器: diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index c334b5e..a0773fd 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -114,7 +114,7 @@ | 键盘可达与焦点可见 | 已完成 | 全部 40 课都由 `bun run e2e:keyboard` 在无头 Chrome 里发真实按键事件复验(328 项检查:结构检查每课 8 项 + S1-02 与 S3-01 的键盘深路径每课 4 项),覆盖 Tab 绕圈全程可见焦点环、无正数 tabindex 打乱顺序、音效开关 Space 可切换、词条 Enter/Escape 与焦点归还、步进门闩挡得住、提交后焦点交给结果区;`tests/keyboard-access-contract.test.ts` 在 `bun test` 里逐课锁住两处焦点恢复动作(作答重画题面、提交成功),焦点环观感与读屏语气仍待人工确认 | | 可复制的代码模板与错误对照 | 下一步 | 帮助初学者辨识常见 C++ 错误;不在浏览器端执行不受控代码 | | 教师备课打印版 | 下一步 | 便于课堂使用;默认不包含学生账号或行为追踪 | -| 可选的公开静态发布 | 已完成 | 课程站点发布在 ,由 Cloudflare Worker `csp-cpp-courseware` 托管静态资源;`bun run build` 从仓库内容重建 `dist/`,`bun run deploy` 先构建再发布,线上内容就是仓库的构建结果;课程地址的末尾斜杠跳转由静态资源的 `html_handling` 负责(Worker 看不到这类请求),Worker 只把 404 换成中文说明。零账号、零排行榜、零在线判题、零个人画像的边界不变 | +| 可选的公开静态发布 | 已完成 | 课程站点发布在 ,由 Cloudflare Worker `csp-cpp-courseware` 托管静态资源;`bun run build` 从仓库内容重建 `dist/`,`bun run deploy` 先构建再发布,线上内容就是仓库的构建结果;课程地址的末尾斜杠跳转由静态资源的 `html_handling` 负责(Worker 看不到这类请求),Worker 只把 404 换成中文说明。main 上的推送由 `.github/workflows/test-and-deploy.yml` 自动发布(先 `bun test`,再 `bun run deploy`,最后抽检线上每一节课、末尾斜杠跳转、词条面板与中文 404),需要仓库 secret `CLOUDFLARE_API_TOKEN`。零账号、零排行榜、零在线判题、零个人画像的边界不变 | ## 不做 diff --git a/docs/roadmap.md b/docs/roadmap.md index dcd1447..df51804 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -128,7 +128,7 @@ - **S5-08 引导式在线课件**:`lessons/s5-08/index.html` 已实现整理知识地图/标注薄弱模块判断选择、知识地图/薄弱项标记/赛前清单三步、薄弱模块复习优先级与 CSP-S 衔接边界两个边界对照,并在只读脚手架里亲手写出一行 `int cnt[105] = {0};`(写成 `= 0` 只把第一个格子设成 0、只声明不给初值、漏掉元素类型 `int` 或末尾分号都会被点出原因,只做文字规则检查,不编译或执行学习者写的 C++)、可见反馈和本地进度恢复。 - **S5-08 随机选择题小测**:从 7 道待人工审校选择题中随机抽取 3 道;同次无重复,提交后显示得分与解析。题库来源为本课 course-plan / 自编。 - **初学者常见问题词条**:`glossary/faq.json` 提供基础解释;S1-01 中的 `g++`、`.cpp`、`main`、`cout` 等可点开查看;S1-02 中的变量、`int`、`char`、赋值等同样可点开;S1-03 中的表达式、整除、取模、优先级和括号同样可点开;S1-04 中的 `cin`、换行、固定小数和提示语同样可点开;S1-05 中的 `if`、`else if`、`==` 和 `&&` 同样可点开;S1-06 中的 `for`、`while`、循环变量、累加和死循环同样可点开;S1-07 中的嵌套循环、外层、内层、`break` 和 `continue` 同样可点开;S1-08 中的编译错误、运行错误、答案错误、缩进、命名和分段输出同样可点开;S2-01 中的数组、下标、长度、初始化和越界同样可点开;S2-02 中的 `char`、`string`、长度、下标和 ASCII 同样可点开;S2-03 中的函数、参数、返回值、调用和定义同样可点开;S2-04 中的作用域、局部变量、全局变量、值传递和引用同样可点开;S2-05 中的结构体、成员、成员访问、结构体数组和按字段比较同样可点开;S2-06 中的冒泡思想、sort、比较规则、交换和稳定性同样可点开;S2-07 中的枚举、枚举范围、状态更新、模拟和漏分支同样可点开;S2-08 中的操作次数、O(n)、O(n²)、边界数据和测试点同样可点开;S3-01 中的整除、取模、数位、最大公约数和循环不变量同样可点开;S3-02 中的递归、递归边界、递归调用、调用栈和阶乘同样可点开;S3-03 中的二分查找、单调性、左右边界、中点和死循环同样可点开;S3-04 中的前缀和、前缀和数组、区间和、下标偏移和 O(n) 同样可点开;S3-05 中的双指针、滑动窗口、左指针、右指针和窗口条件同样可点开;S3-06 中的贪心选择、选择标准、反例、排序后决策和 sort 同样可点开;S3-07 中的栈、队列、后进先出、先进先出、括号匹配和广度优先直觉同样可点开;S3-08 中的动态规划、状态、转移、初始化和一维 DP 同样可点开;S4-01 中的竞赛程序、数据范围、样例、标准输入输出和干净输出同样可点开;S4-02 中的建模卡、已知与未知、样例反推、约束和算法候选同样可点开;S4-03 中的状态表、事件顺序、条件分支、样例追踪和边界数据同样可点开;S4-04 中的深度优先搜索、搜索树、回溯和剪枝同样可点开;S4-05 中的数据范围、sort、二分查找、计数数组和重复元素同样可点开;S4-06 中的状态、转移、初始化、选或不选、最优子结构和状态压缩同样可点开;S4-07 中的图、邻接表、访问标记、深度优先搜索、广度优先直觉和连通块同样可点开;S4-08 中的子任务、部分分、保底方案、先易后难和复杂度降级同样可点开;S5-01 中的限时解题流程、审题计时、建模草稿、样例检查和预留检查时间同样可点开;S5-02 中的系统化调试、最小复现、分段输出、断言思路和差分检查同样可点开;S5-03 中的溢出、long long、数组初始化、下标越界、运算优先级、== 比较相等和干净输出同样可点开;S5-04 中的暴力基线、子任务、特殊情况、复杂度升级、伪优化和差分检查同样可点开,S5-05 中的模拟赛、提交顺序、自测表和时间记录同样可点开;S5-06 中的错误分类、边界数据、改法和重做关键题同样可点开;S5-07 中的新题组、交叉检查、对照上一次和稳定习惯同样可点开;S5-08 中的知识地图、薄弱模块、赛前检查清单和 CSP-S 衔接条件同样可点开,并给出可选的权威外部链接。 -- **静态站点构建与发布**:`bun run build` 把 `index.html`、`glossary/` 与 `lessons/` 重新复制到 `dist/`,`bun run deploy` 先构建再用 `bunx wrangler deploy` 发布到 Cloudflare Worker `csp-cpp-courseware`(自定义域名 ;配置见 `wrangler.jsonc`,课程地址的末尾斜杠跳转由静态资源的 `html_handling` 负责,Worker 入口 `src/index.ts` 只把 404 换成中文说明)。产物只能来自仓库内容,因此不会再出现「仓库已经补到 40 节课、线上还停在 4 节课」的漂移——那次漂移的根因正是构建与部署只在某台机器上手工跑过一次、此后再没人重跑。`tests/deploy-assets.test.ts` 在 `bun test` 里守住这条链路:产物覆盖 `courseData` 的每一节课、旧产物不残留、课程地址的末尾斜杠跳转与中文 404 仍在、部署目标与文档一致。同一套浏览器复验也可以直接打在线上:`CSP_E2E_ORIGIN=https://cplus.talkincode.net bun tests/e2e/flow-cdp.ts` 会复用已在运行的服务、不再自启开发服务器,线上已跑过 S5-07/S5-08 的整课推进与 S5-08/S3-01 的微练习。 +- **静态站点构建与发布**:`bun run build` 把 `index.html`、`glossary/` 与 `lessons/` 重新复制到 `dist/`,`bun run deploy` 先构建再用 `bunx wrangler deploy` 发布到 Cloudflare Worker `csp-cpp-courseware`(自定义域名 ;配置见 `wrangler.jsonc`,课程地址的末尾斜杠跳转由静态资源的 `html_handling` 负责,Worker 入口 `src/index.ts` 只把 404 换成中文说明)。产物只能来自仓库内容,因此不会再出现「仓库已经补到 40 节课、线上还停在 4 节课」的漂移——那次漂移的根因正是构建与部署只在某台机器上手工跑过一次、此后再没人重跑。`tests/deploy-assets.test.ts` 在 `bun test` 里守住这条链路:产物覆盖 `courseData` 的每一节课、旧产物不残留、课程地址的末尾斜杠跳转与中文 404 仍在、部署目标与文档一致。同一套浏览器复验也可以直接打在线上:`CSP_E2E_ORIGIN=https://cplus.talkincode.net bun tests/e2e/flow-cdp.ts` 会复用已在运行的服务、不再自启开发服务器,线上已跑过 S5-07/S5-08 的整课推进与 S5-08/S3-01 的微练习。发布默认不再靠人记得:main 上的推送由 `.github/workflows/test-and-deploy.yml` 自动跑「`bun test` → `bun run deploy` → 线上抽检(每一节课、末尾斜杠跳转、词条面板、中文 404)」,缺 `CLOUDFLARE_API_TOKEN` 时发布步骤明确失败而不是静默跳过。 - **内容结构校验**:`tests/curriculum.test.ts` 会校验 5 个阶段、40 节课、唯一标识及每节课的目标/内容/检验项结构。 - **手工端到端脚本**:`tests/e2e/manual-checklist.md` 覆盖导航、课件阅读和本地进度恢复的基础路径。 @@ -281,11 +281,11 @@ | 题库检查目标与课程学习目标绑定 | 中 | ✅ 40 课 215 道题逐题带 `objectiveIndex`,讲义页渲染的「检查目标」就是本课学习目标原文,另起一行给出更细的「本题检查」,且 40 课的三条学习目标都有题目覆盖;`bun run e2e:s1-01`(88 项)、`bun run e2e:typed`(39 课共 659 项)与 `bun run e2e:flow`(39 课共 741 项)在真实浏览器复验 | ✅ 目标越界、讲义目标副本与 `courseData` 漂移、题库题数与元数据不符、`docs/feature-checklist.md` 写的题库题数与现场不符、未通过的试卷说不出错题落在哪条目标、文档把未覆盖的学习目标点名成已被覆盖的那条,都会当场失败 | 不适用:无账号 | 不适用:静态校验不修改已发布题库 | [`tests/objective-binding.test.ts`](../tests/objective-binding.test.ts)、[`tests/quiz-facts-prose.test.ts`](../tests/quiz-facts-prose.test.ts)、[`S1-01 E2E`](../tests/e2e/s1-01-manual-checklist.md#自动化复验记录)、[`tests/e2e/s2-typed-cdp.ts`](../tests/e2e/s2-typed-cdp.ts) | | 课后继续学习入口 | 中 | ✅ 39 节非末课走完 `N / N` 后自己打开 `#nextStep`,写明下一课是 `Sx-xx` 与课名、先练的那件事,链接直达下一课的 `index.html` 且真的可达;S5-08 作为最后一课换成课程收尾出口并指回课程路线。真实浏览器复验见 `bun run e2e:flow`(2026-09-21 Chrome 153 无头,每课 19 项) | ✅ 没做完之前入口是隐藏的,不会把还在这一课上的学习者提前引走;边界/失败路径另由 `tests/next-lesson.test.ts` 逐课看住:入口在「视频边界」说明之前、一页出现两个入口、链接指向 `../sX-XX/`(大写或大写目录名)、指向不存在的下一课、标题或先练目标与 `courseData` 漂移、末课仍留指向不存在的下一课、或出现成绩评定/证书/保过之类的说法,都会当场失败 | 不适用:无账号 | ✅ 清掉本地存储后入口重新隐藏、页面回到 `0 / N`,不依赖任何服务端状态;下一课链接用的是相对路径,开发服务器与静态托管下都成立 | [`tests/next-lesson.test.ts`](../tests/next-lesson.test.ts)、[`E2E-flow`](../tests/e2e/flow-cdp.ts)、[`flow 清单`](../tests/e2e/flow-manual-checklist.md#e2e-flow-05-课后继续学习入口) | -| 静态站点构建与发布 | 中 | ✅ `bun run build` 从仓库内容重建 `dist/`(40 节课页面 + 课程目录 + 词条面板),`bun run deploy` 先构建再发布到 `cplus.talkincode.net`;2026-09-21 发布后 40 节课地址全部返回 200、`/lessons/s5-08/` 可达、词条面板可载入,线上页面与本地构建逐字节一致(只有 Cloudflare 注入的脚本不同),并用 `CSP_E2E_ORIGIN=https://cplus.talkincode.net` 把真实浏览器复验直接打在线上:`e2e:typed` 的 s5-08 + s3-01 两课 34 项通过,`e2e:flow` 的 s5-07 + s5-08 两课 38 项通过 | ✅ 产物缺课、`dist/` 里残留上一次的旧页面、课程地址的末尾斜杠跳转(`html_handling`)或中文 404 丢失、部署目标(Worker 名 / `dist` 目录 / 域名)与文档漂移,都会由 [`tests/deploy-assets.test.ts`](../tests/deploy-assets.test.ts) 当场失败 | 不适用:无账号 | ✅ 构建每次清空重建 `dist/`,重复发布幂等;线上出错可用 `bunx wrangler rollback` 退回上一个版本 | [`tests/deploy-assets.test.ts`](../tests/deploy-assets.test.ts)、[`scripts/build.ts`](../scripts/build.ts)、[`src/index.ts`](../src/index.ts)、[`wrangler.jsonc`](../wrangler.jsonc) | +| 静态站点构建与发布 | 中 | ✅ `bun run build` 从仓库内容重建 `dist/`(40 节课页面 + 课程目录 + 词条面板),`bun run deploy` 先构建再发布到 `cplus.talkincode.net`;2026-09-21 发布后 40 节课地址全部返回 200、`/lessons/s5-08/` 可达、词条面板可载入,线上页面与本地构建逐字节一致(只有 Cloudflare 注入的脚本不同),并用 `CSP_E2E_ORIGIN=https://cplus.talkincode.net` 把真实浏览器复验直接打在线上:`e2e:typed` 的 s5-08 + s3-01 两课 34 项通过,`e2e:flow` 的 s5-07 + s5-08 两课 38 项通过 | ✅ 产物缺课、`dist/` 里残留上一次的旧页面、课程地址的末尾斜杠跳转(`html_handling`)或中文 404 丢失、部署目标(Worker 名 / `dist` 目录 / 域名)与文档漂移,都会由 [`tests/deploy-assets.test.ts`](../tests/deploy-assets.test.ts) 当场失败 | 不适用:无账号 | ✅ 构建每次清空重建 `dist/`,重复发布幂等;线上出错可用 `bunx wrangler rollback` 退回上一个版本;main 上的推送由 [`.github/workflows/test-and-deploy.yml`](../.github/workflows/test-and-deploy.yml) 自动发布(`bun test` 通过才发布,发布后抽检线上) | [`tests/deploy-assets.test.ts`](../tests/deploy-assets.test.ts)、[`tests/ci-workflow.test.ts`](../tests/ci-workflow.test.ts)、[`scripts/build.ts`](../scripts/build.ts)、[`src/index.ts`](../src/index.ts)、[`wrangler.jsonc`](../wrangler.jsonc)、[`.github/workflows/test-and-deploy.yml`](../.github/workflows/test-and-deploy.yml) | 本项目当前无权限模型,故权限角色覆盖均不适用;一旦新增账号或教师端,相关行必须立即改为双角色验证,且在实现前不得宣称功能完成。 -题库的结构性约束(课程 ID、难度档位、题型、答案索引、解析、来源、组卷唯一性与可复现性)已由自动化校验守住,但答案的语义正确性、题目措辞与「检查目标」文字仍属人工审校范围:全部题目保持 `awaiting-user-review`,未审校前不得宣称“内容已审校”。2026-09-21 起「检查目标」不再是自由文本:每道题带 `objectiveIndex`,讲义页显示的「检查目标」直接取本课 `courseData` 的学习目标原文,并在下一行给出更细的「本题检查」;补充题库时只需在 `index.html` 里填这条索引,页面文案就跟着学习目标走。`tests/objective-binding.test.ts` 守住这条绑定:讲义里的目标副本要与 `courseData` 逐条一致、索引必须落在本课目标范围内、`lesson.json` 里的覆盖数据要与现场相符,改漏任何一处都会失败。绑定只解决「题目对得上哪条目标」,不证明答案语义正确,题目与解析仍需人工审校。逐课覆盖实况写在 `lesson.json` 的 `assessment.objectiveCoverage` 与 `uncoveredObjectives` 里:40 课三条目标全覆盖,已经没有“某条学习目标在题库里一道题都没考到”的课——`lessons/s1-03/`(补「把文字公式翻译成 C++ 表达式」)、`lessons/s1-08/`(补「养成可检查的代码书写习惯」)、`lessons/s5-06/`(补「重做关键题验证修复」)三条缺口已在补题后划掉。这份点名清单由 `tests/quiz-facts-prose.test.ts` 绑回课程事实:写成别的课的目标、或者补完题忘了划掉旧缺口都会失败。仓库目前没有 CI,发布前需人工执行 `bun test`。 +题库的结构性约束(课程 ID、难度档位、题型、答案索引、解析、来源、组卷唯一性与可复现性)已由自动化校验守住,但答案的语义正确性、题目措辞与「检查目标」文字仍属人工审校范围:全部题目保持 `awaiting-user-review`,未审校前不得宣称“内容已审校”。2026-09-21 起「检查目标」不再是自由文本:每道题带 `objectiveIndex`,讲义页显示的「检查目标」直接取本课 `courseData` 的学习目标原文,并在下一行给出更细的「本题检查」;补充题库时只需在 `index.html` 里填这条索引,页面文案就跟着学习目标走。`tests/objective-binding.test.ts` 守住这条绑定:讲义里的目标副本要与 `courseData` 逐条一致、索引必须落在本课目标范围内、`lesson.json` 里的覆盖数据要与现场相符,改漏任何一处都会失败。绑定只解决「题目对得上哪条目标」,不证明答案语义正确,题目与解析仍需人工审校。逐课覆盖实况写在 `lesson.json` 的 `assessment.objectiveCoverage` 与 `uncoveredObjectives` 里:40 课三条目标全覆盖,已经没有“某条学习目标在题库里一道题都没考到”的课——`lessons/s1-03/`(补「把文字公式翻译成 C++ 表达式」)、`lessons/s1-08/`(补「养成可检查的代码书写习惯」)、`lessons/s5-06/`(补「重做关键题验证修复」)三条缺口已在补题后划掉。这份点名清单由 `tests/quiz-facts-prose.test.ts` 绑回课程事实:写成别的课的目标、或者补完题忘了划掉旧缺口都会失败。发布不再是人工动作:main 上的每次推送由 `.github/workflows/test-and-deploy.yml` 先跑 `bun test` 再 `bun run deploy`,并在发布后抽检线上(每一节课、末尾斜杠跳转、词条面板、中文 404);`bun test` 仍然要在本地改动时自己跑,CI 只是兜底。 ## 维护规则 @@ -294,5 +294,5 @@ - 新增一级功能时,同步新增 Happy Path E2E、更新本矩阵,并满足适用的失败/权限/恢复覆盖底线。 - 课程从“规划”变为“在线可学习”时,互动界面、测试、题库元数据及其验收证据必须一起到位;缺一项就只能标为规划。视频课件由仓库外流程管理,不作为本仓库实现状态的前置条件。 - 题库、答案、解析和随机组卷规则变更时,必须重新验证同次无重复、选择题占多数、检查目标与 `courseData` 学习目标的绑定(`objectiveIndex`、讲义里的目标副本、`lesson.json` 的覆盖数据)、答案正确性和可复现性。 -- 发布静态站点时先跑 `bun test`,只用 `bun run deploy` 生成产物(它内部会先 `bun run build`);不得手工拼装 `dist/` 或发布未提交的改动,线上内容必须等于仓库的构建结果。 +- 发布静态站点只走 `bun run deploy` 生成产物(它内部会先 `bun run build`);main 上的推送由 `.github/workflows/test-and-deploy.yml` 自动完成「`bun test` → 发布 → 线上抽检」,人工发布是它不可用时的兜底。不得手工拼装 `dist/` 或发布未提交的改动,线上内容必须等于仓库的构建结果。 - 只有目标画像、边界或一级能力变化时才更新本文档;日常任务进度不应污染项目画像。 diff --git a/tests/ci-workflow.test.ts b/tests/ci-workflow.test.ts new file mode 100644 index 0000000..4644aee --- /dev/null +++ b/tests/ci-workflow.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// 站点曾经停在 4 节课,而仓库已经补到 40 节课:构建与部署只在某台机器上手工跑过一次, +// 之后没人重跑。现在 main 一合就由 GitHub Actions 发布,这条工作流本身也要被守住—— +// 触发条件、测试先于发布、凭据、以及发布后对线上的抽检,缺一样都会让「自动发布」名不副实。 +const projectRoot = `${import.meta.dir}/..`; +const workflowPath = ".github/workflows/test-and-deploy.yml"; + +type Step = { name?: string; uses?: string; run?: string; with?: Record }; +type Job = { needs?: string | string[]; if?: string; env?: Record; steps: Step[] }; +type Workflow = { + on?: Record; + permissions?: Record; + jobs?: Record; +}; + +const workflow: Workflow = Bun.YAML.parse(readFileSync(resolve(projectRoot, workflowPath), "utf8")); +const jobs = workflow.jobs ?? {}; +const deploy = jobs.deploy; +const testJob = jobs.test; + +function runScripts(job: Job | undefined): string { + return (job?.steps ?? []).map((step) => step.run ?? "").join("\n"); +} + +test("工作流在 main 推送、PR 与手动触发下运行", () => { + expect(existsSync(resolve(projectRoot, workflowPath))).toBe(true); + expect(Object.keys(workflow.on ?? {}).sort()).toEqual(["pull_request", "push", "workflow_dispatch"]); + expect((workflow.on?.push as { branches?: string[] })?.branches).toEqual(["main"]); + // 只读权限:发布不需要写仓库内容 + expect(workflow.permissions?.contents).toBe("read"); +}); + +test("发布前必须先跑 bun test,且发布只在 main 上发生", () => { + expect(runScripts(testJob)).toContain("bun test"); + expect(deploy?.needs).toBe("test"); + + const condition = deploy?.if ?? ""; + expect(condition).toContain("refs/heads/main"); + // PR 上不给 secrets,也不能发布 + expect(condition).toContain("github.event_name == 'push'"); +}); + +test("发布走 bun run deploy,不允许绕过构建直接推 dist", () => { + const scripts = runScripts(deploy); + + expect(scripts).toContain("bun run deploy"); + // 直接 wrangler deploy 会跳过 build,把上一次的旧产物发出去——正是要防的事故 + expect(scripts).not.toContain("wrangler deploy"); + expect(scripts).not.toContain("dist"); +}); + +test("凭据只从仓库 secret 取,缺凭据时明确失败而不是静默跳过", () => { + const env = deploy?.env ?? {}; + const scripts = runScripts(deploy); + + expect(env.CLOUDFLARE_API_TOKEN).toBe("${{ secrets.CLOUDFLARE_API_TOKEN }}"); + // 账号 ID 不是凭据,随工作流一起写在仓库里 + expect(env.CLOUDFLARE_ACCOUNT_ID).toMatch(/^[0-9a-f]{32}$/); + expect(scripts).toContain('if [ -z "${CLOUDFLARE_API_TOKEN}" ]'); + expect(scripts).toContain("::error::"); +}); + +test("发布后抽检线上:每一节课、末尾斜杠跳转、词条面板与中文 404", () => { + const scripts = runScripts(deploy); + + // 抽检的课来自仓库自己的 lessons 目录,新课自动纳入 + expect(scripts).toContain("for dir in lessons/*/"); + expect(scripts).toContain("glossary/faq-panel.js"); + expect(scripts).toContain("页面未找到"); + // 发布刚生效时可能有传播延迟,失败要重试而不是立刻判死 + expect(scripts).toContain("attempt"); + // 抽检的域名必须与 wrangler.jsonc 里的部署目标一致 + const config = JSON.parse(readFileSync(resolve(projectRoot, "wrangler.jsonc"), "utf8")); + expect(scripts).toContain(config.routes[0].pattern); +}); + +test("文档写清了自动发布与它需要的 secret", () => { + const readme = readFileSync(resolve(projectRoot, "README.md"), "utf8"); + + expect(readme).toContain("CLOUDFLARE_API_TOKEN"); + expect(readme).toContain(workflowPath); + expect(readme).toContain("cplus.talkincode.net"); +});