From 115bc0b893ebbb434fe2533900b0b51e51acef97 Mon Sep 17 00:00:00 2001 From: cadywang <109458837+cadywang@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:57:45 +0800 Subject: [PATCH] ci: gate publishing behind manual review Source commits land on main first and are only validated there. The site is built and deployed only when a human explicitly triggers the publish workflow, so a push alone can never go live. - review-gate.yml: validate-posts, pr-checklist and build-check run on pull requests and on main, and never deploy anything - deploy.yml: publish on workflow_dispatch or a publish-* tag, with a verify step that refuses commits which are not on main, a full post validation before the build, and a publish summary in the run - tools/publish-summary.mjs: list the commits and the post titles that the pending publish would contain - tools/check-posts.mjs, tools/check-pr.mjs: dependency free checks used by both workflows, plus npm run check:posts locally - add CODEOWNERS, a pull request template and REVIEW_POLICY.md, and rewrite the README publishing flow for the solo review model --- .github/CODEOWNERS | 8 + .github/pull_request_template.md | 34 ++++ .github/workflows/deploy.yml | 93 ++++++++-- .github/workflows/review-gate.yml | 70 ++++++++ README.md | 69 +++++--- REVIEW_POLICY.md | 101 +++++++++++ package.json | 3 +- tools/check-posts.mjs | 281 ++++++++++++++++++++++++++++++ tools/check-pr.mjs | 73 ++++++++ tools/publish-summary.mjs | 144 +++++++++++++++ 10 files changed, 842 insertions(+), 34 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/review-gate.yml create mode 100644 REVIEW_POLICY.md create mode 100644 tools/check-posts.mjs create mode 100644 tools/check-pr.mjs create mode 100644 tools/publish-summary.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a2315f4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# 发布审查:source/_posts/ 下的任何改动都需要审查人批准。 +# +# 注意:CODEOWNERS 自己不会阻止合并,必须在仓库规则里勾选 +# Settings → Rules → Rulesets → Require review from Code Owners +# (详见 REVIEW_POLICY.md)。单人仓库要先加一个协作者账号, +# 否则自己的 PR 永远拿不到批准,会被自己的规则卡死。 + +/source/_posts/ @cadywang \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d49e29b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,34 @@ + + +## 变更内容 + + + +## 变更类型 + +- [ ] 新文章 +- [ ] 修改 / 删除已有文章 +- [ ] 站点、主题或配置调整 +- [ ] 依赖或工作流调整 + +## 发布审查清单 + +- [ ] 文章标题、日期、标签、分类已填写完整 +- [ ] 正文已通读:无错别字、无未写完的段落、无占位内容 +- [ ] 图片等本地资源已随文章一起提交,路径可访问 +- [ ] 不含密钥、口令、内网地址等敏感信息 +- [ ] 已在本地执行 `npm run build` 且构建通过 + +## 审查人重点看什么 + + + +## 关联 Issue + + \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4872796..7c8e55c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,11 +1,30 @@ -name: Build and Deploy Blog +name: Publish Blog + +# 发布流程(单人模式):源码先提交,审核通过后再手动触发编译发布。 +# +# 1. 平时:把文章源码提交到 main(网页提交或本地 push 都行), +# review-gate.yml 会自动校验文章格式并试构建,但**不会发布**; +# 2. 你本人审核:在 GitHub 上看这次 diff,或本地 npm run server 预览; +# 3. 审核通过后,用下面任一方式触发本工作流,才会真正「编译 + 发布」: +# · Actions 页面 → Publish Blog → Run workflow(推荐,可填审核备注) +# · 或打一个 publish-* 标签并推送:git push origin publish-2026-10-15 +# +# 也就是说:**没有你这一下人工触发,任何提交都不会上线。** on: + workflow_dispatch: + inputs: + ref: + description: 要发布的提交(留空 = 当前分支最新提交,通常是 main) + required: false + default: '' + note: + description: 审核备注(会写进发布摘要,便于事后追溯) + required: false + default: '' push: - branches: [main] # 合并/推送到 main → 构建并发布 - pull_request: - branches: [main] # PR → 只构建校验,不发布 - workflow_dispatch: # 也支持在 Actions 页面手动触发 + tags: + - 'publish-*' permissions: contents: read @@ -13,39 +32,87 @@ permissions: id-token: write concurrency: - group: pages-${{ github.ref }} - cancel-in-progress: true + group: pages-publish + cancel-in-progress: false jobs: + verify: + name: verify + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.target.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.sha }} + fetch-tags: true + fetch-depth: 0 + - name: 确认要发布的提交已经在 main 上 + id: target + env: + NOTE: ${{ github.event.inputs.note }} + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + git fetch --no-tags origin main:refs/remotes/origin/main --force + if ! git merge-base --is-ancestor "$sha" origin/main; then + echo "::error::要发布的提交 $sha 不在 main 上,拒绝发布(请先把源码合并/推送到 main)" + exit 1 + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + { + echo "## 发布审核" + echo "" + echo "| 项目 | 内容 |" + echo "| --- | --- |" + echo "| 目标提交 | \`${sha}\` |" + echo "| 提交说明 | $(git log -1 --pretty=%s) |" + echo "| 提交作者 | $(git log -1 --pretty='%an <%ae>') |" + echo "| 提交时间 | $(git log -1 --pretty=%ad --date=iso) |" + echo "| 审核备注 | ${NOTE:-(未填写)} |" + echo "| 触发方式 | ${{ github.event_name }} |" + echo "| 触发人 | ${{ github.actor }} |" + echo "" + echo "> 这一下「Run workflow / 打 tag」就是本次发布的人工审核动作。" + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: 汇总本次要发布的改动 + run: node tools/publish-summary.mjs "${{ steps.target.outputs.sha }}" + build: + name: build + needs: verify runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.sha }} - uses: actions/setup-node@v4 with: node-version: 20 cache: npm + - name: 发布前全量校验文章 + run: node tools/check-posts.mjs --all - name: Install dependencies run: npm ci - name: Build site run: npx hexo clean && npx hexo generate - uses: actions/configure-pages@v5 - if: github.event_name != 'pull_request' with: enablement: true - uses: actions/upload-pages-artifact@v3 - if: github.event_name != 'pull_request' with: path: public deploy: - # PR 不发布:只有 push 到 main 或手动触发才部署 - if: github.event_name != 'pull_request' - needs: build + name: deploy + needs: [verify, build] runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/review-gate.yml b/.github/workflows/review-gate.yml new file mode 100644 index 0000000..4b24698 --- /dev/null +++ b/.github/workflows/review-gate.yml @@ -0,0 +1,70 @@ +name: Review Gate + +# 这是「发布前的自动校验」,只校验、不发布: +# validate-posts —— 文章的 front-matter / 文件名 / 本地资源校验 +# pr-checklist —— 走 PR 时,PR 描述里的「发布审查清单」必须逐条勾选 +# build-check —— 确认站点能构建(npm ci + hexo generate) +# +# 真正上线由 deploy.yml 手动触发(Actions → Publish Blog → Run workflow), +# 所以这里失败只是提醒你「先修好再发布」。完整规则见 REVIEW_POLICY.md。 +# +# 注意:job 名字(validate-posts / pr-checklist / build-check)如果要写进 +# 仓库规则集的必需检查里,改名必须同步更新 REVIEW_POLICY.md。 + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: review-gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate-posts: + name: validate-posts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: 校验文章格式 + env: + BASE_REF: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}" + run: node tools/check-posts.mjs --base "$BASE_REF" + + pr-checklist: + name: pr-checklist + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: 校验 PR 审查清单 + env: + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node tools/check-pr.mjs + + build-check: + name: build-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Build site + run: npx hexo clean && npx hexo generate \ No newline at end of file diff --git a/README.md b/README.md index c88ae18..b5f029e 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,26 @@ - 线上地址: - 仓库地址: +- 发布规则:[REVIEW_POLICY.md](REVIEW_POLICY.md)——**源码先提交,本人审核通过后手动触发才编译发布** - 工作流:[.github/workflows/deploy.yml](.github/workflows/deploy.yml) --- -## 一、日常使用:只需要放文档 +## 一、日常使用:写文章 → 审核 → 发布 -你不需要懂前端,也不需要本地装任何东西,**只要往 `source/_posts/` 目录上传 Markdown 文件**,博客就会自动更新。 +你不需要懂前端,也不需要本地装任何东西,**只要往 `source/_posts/` 目录上传 Markdown 文件**,提交后自己审核一遍,再在 Actions 里点一次发布就能上线(规则见 [REVIEW_POLICY.md](REVIEW_POLICY.md))。 -### 在网页上发一篇新文章 +### 发一篇新文章(源码先提交,审核后再发布) + +> 本站是**单人模式**:源码提交到 `main` **不会**立刻上线。 +> 你自己审核通过后,在 Actions 里手动触发一次发布,才会编译并上线。 +> 完整规则见 [REVIEW_POLICY.md](REVIEW_POLICY.md)。 + +**第一步:提交文章源码** 1. 打开 2. 点右上角 **Add file → Create new file** -3. 文件名填 `my-new-post.md`(**建议用英文/拼音**,它会出现在文章网址里) +3. 文件名填 `my-new-post.md`(建议用英文/拼音,它会出现文章网址里) 4. 内容格式: ```markdown @@ -30,10 +37,25 @@ 正文,正常写 Markdown 即可。 ``` -5. 页面底部点 **Commit changes** -6. 大约 1~2 分钟后,刷新 即可看到 +5. 页面底部选 **Commit directly to the main branch**,点 **Commit changes** + +**第二步:等自动校验,然后自己审核** + +6. 仓库 **Actions** 里 `Review Gate` 会跑 `validate-posts`(文章格式)和 `build-check`(能否构建),全绿说明没问题 +7. 审核方式随你挑:看这次提交的 diff、在 GitHub 上读一遍源码,或本地 `npm run server` 预览效果 -> 修改已有文章:点开该文件 → 右上角铅笔图标 → 改完提交,同样会自动发布。 +**第三步:审核通过 → 手动触发发布** + +8. 打开 +9. 点 **Run workflow** → 分支选 `main` →(可选)填一句审核备注 → 点 **Run workflow** +10. 看这次运行的 **Summary** 里「本次发布内容」,确认要发的东西没错,再等 `build` / `deploy` 变绿 +11. 约 1~2 分钟刷新 即可看到 + +> 命令行等价操作:`gh workflow run deploy.yml`;或者打标签 `git tag publish-2026-10-15 && git push origin publish-2026-10-15`。 +> 两种方式都只会发布 `main` 上已有的提交,未进 main 的东西发布不出去。 +> +> 修改已有文章同理:改完提交 → 校验变绿 → 手动发布一次。 +> 还没打算发的稿子:在 front-matter 加 `draft: true`,它不会被生成。 ### front-matter 各项含义 @@ -133,19 +155,26 @@ npm run new -- "标题" # 按模板新建一篇文章 ## 五、发布原理 -``` -提交 Markdown 到 main 分支 - │ - ▼ -GitHub Actions(ubuntu-latest) - 1. checkout 代码 - 2. 安装 Node 20 + npm 依赖 - 3. npx hexo generate → 生成 public/ 静态文件 - 4. 上传产物并发布到 GitHub Pages - │ - ▼ -https://cadywang.github.io 自动更新 -``` +提交文章源码到 main(直接提交或走 PR 都行) + │ + ▼ +Review Gate(自动校验,不发布) + validate-posts 文章格式 / 文件名 / 本地资源 + pr-checklist 走 PR 时的审查清单 + build-check 站点能否构建成功 + │ + ▼ +你本人审核(看 diff / 本地预览) + │ 审核通过 + ▼ +Actions → Publish Blog → Run workflow(人工触发,或推 publish-* 标签) + verify 确认要发布的提交已经在 main 上 + 全量校验 check-posts --all + 构建 npm ci + hexo generate + 发布 deploy-pages → GitHub Pages + │ + ▼ +https://cadywang.github.io 更新 查看每次发布是否成功:仓库页 **Actions** 标签。 diff --git a/REVIEW_POLICY.md b/REVIEW_POLICY.md new file mode 100644 index 0000000..5c4d505 --- /dev/null +++ b/REVIEW_POLICY.md @@ -0,0 +1,101 @@ +# 发布规则(REVIEW_POLICY) + +> 一句话:**源码提交 ≠ 上线。** 文章源码先提交到 `main`(自动校验但**不发布**), +> 由你本人审核通过后,在 Actions 里手动触发一次发布,CI 才会编译并上线。 +> 没有你这一下人工触发,任何提交都不会发到线上。 + +--- + +## 一、两条流水线 + +| 流水线 | 触发方式 | 做什么 | 会不会上线 | +| --- | --- | --- | --- | +| **Review Gate**
[.github/workflows/review-gate.yml](.github/workflows/review-gate.yml) | 提交/推送到 `main`、开 PR | `validate-posts`(文章格式校验)
`pr-checklist`(走 PR 时的清单校验)
`build-check`(npm ci + hexo generate 试构建) | **不会** | +| **Publish Blog**
[.github/workflows/deploy.yml](.github/workflows/deploy.yml) | **你手动触发**:Actions → Publish Blog → Run workflow
或推一个 `publish-*` 标签 | `verify`(确认目标提交已在 main 上)
全量校验 `check-posts --all`
构建 → `deploy-pages` 发布 | **会** | + +## 二、日常发布三步 + +```bash +# 1) 写文章并提交(网页提交、或本地提交后 push 到 main 都可以) +vim source/_posts/my-new-article.md +npm run check:posts # 可选:本地先自查 +git add source/_posts/my-new-article.md +git commit -m "post: add my new article" +git push origin main + +# 2) 等 Review Gate 变绿,然后自己审核:看 diff、或本地预览 +npm run server # http://localhost:4000 + +# 3) 审核通过 → 手动触发发布(这就是「我审核通过了」这个动作) +gh workflow run deploy.yml # 或者去 Actions 页面点 Run workflow + # 也可以:git tag publish-2026-10-15 && git push origin publish-2026-10-15 +``` + +网页操作版:仓库 **Actions** → 左侧 **Publish Blog** → 右侧 **Run workflow** → 分支选 `main` → +(可选)填一句审核备注 → 点绿色 **Run workflow**。 + +## 三、发布前会给你一份「发布清单」 + +手动触发后,`verify` 任务会在这次运行的 **Summary** 页写出「本次发布内容」: + +- 目标提交的 SHA、提交说明、作者、时间; +- 你填的审核备注、触发方式与触发人; +- 与**上一次发布**(`publish-*` 标签)相比:包含哪些提交、哪些文章(列出**文章标题**)、还有多少其他文件改动; +- 没有 `publish-*` 标签时,按「首次发布」列出全部文章。 + +确认清单没问题就让它跑完;发现问题直接 **Cancel workflow**,线上不会有任何变化。 + +## 四、什么时候会被拒绝发布 + +| 情况 | 结果 | +| --- | --- | +| 要发布的提交不在 `main` 上(例如从别的分支手动指定了 ref) | `verify` 失败,拒绝发布 | +| 任何一篇文章校验不通过(缺 front-matter、文件名不规范、引用的图片不存在…) | 全量校验失败,拒绝发布 | +| 站点构建失败(`npm ci` / `hexo generate` 报错) | `build` 失败,不发布 | + +也就是说:**发布这一步永远发生在「校验 + 构建」都通过之后**,坏内容上不了线。 + +## 五、自动校验查什么 + +`validate-posts`([tools/check-posts.mjs](tools/check-posts.mjs)): + +- front-matter 必须存在,`title` / `date` / `tags` 必填且格式正确; +- 文件名必须是全小写 slug(`my-post.md` 或 `2026-10-15-my-post.md`); +- 正文里 `![](...)` / `` 引用的**本地图片必须真实存在**; +- 告警项:`draft: true`、`published: false`、未来日期、正文里的 TODO/占位字样。 + +`pr-checklist`([tools/check-pr.mjs](tools/check-pr.mjs),只有走 PR 时才跑): + +- PR 描述里 [.github/pull_request_template.md](.github/pull_request_template.md) 的「发布审查清单」必须逐条勾选。 + +## 六、可选加固(想要更严时再加) + +现在这套是**单人模式**:靠「发布必须人工触发」来卡住上线。想更严可以叠加: + +1. **分支保护 / 规则集**:Settings → Rules → Rulesets → 限制 `main`(禁止强推与删除、Bypass list 留空), + 这样连往 `main` 直接推都受限;如果同时要求 PR + 状态检查,就变成「必须走 PR」。 +2. **环境审批**:Settings → Environments → `github-pages` → 勾选 Required reviewers(填你自己)。 + 这样发布任务会停在「Waiting for approval」,要再点一次 **Approve** 才执行。 + 注意:GitHub 的环境审批**允许触发者本人自批**(官方没有禁止自批),所以它适合做「发布前再确认一次」, + 不适合用来防越权。 +3. **想做到「必须别人审」**:需要第二个协作者账号,然后在规则集里要求 + `Required approvals = 1` + `Require review from Code Owners`([.github/CODEOWNERS](.github/CODEOWNERS) 已写好)。 + GitHub 不允许自己批准自己的 PR,单人账号用不了这条。 +4. **只允许特定人发布**:仓库只给可信的人写权限;或改成只认 `publish-*` 标签触发,并给标签加保护规则。 + +## 七、本地自查 + +```bash +npm run check:posts # 校验全部文章(等价于发布前的全量校验) +node tools/check-posts.mjs --base=origin/main # 只校验本次改动 +node tools/publish-summary.mjs HEAD # 本地预演「发布清单」 +``` + +## 八、改动规则时的注意事项 + +- 工作流里的 **job 名称**(`validate-posts`、`pr-checklist`、`build-check`)如果要写进规则集的必需检查, + 改名必须同步更新本文档与仓库 Ruleset 配置。 +- 发布触发靠 **`publish-*` 标签前缀**(`deploy.yml` 的 `on.push.tags`)与手动 `workflow_dispatch`, + 改这两处等于改发布入口,务必同步更新本文档和 README。 +- 「发布清单」里与上次发布的比较基准是**最新的 `publish-*` 标签**;如果用 `workflow_dispatch` 发布, + 建议发布成功后打个标签留档,例如:`git tag publish-2026-10-15 && git push origin publish-2026-10-15`。 \ No newline at end of file diff --git a/package.json b/package.json index 455de12..d346253 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "clean": "hexo clean", "build": "hexo generate", "server": "hexo server", - "new": "hexo new" + "new": "hexo new", + "check:posts": "node tools/check-posts.mjs --all" }, "hexo": { "version": "7.3.0" diff --git a/tools/check-posts.mjs b/tools/check-posts.mjs new file mode 100644 index 0000000..516abbf --- /dev/null +++ b/tools/check-posts.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +/* + * 文章发布前校验:发布审查的第一道自动闸门。 + * + * 校验内容: + * 1. source/_posts/ 下的文章必须带合法 front-matter,且 title / date / tags 齐全; + * 2. 文件名必须是全小写的 slug(可带 YYYY-MM-DD- 前缀); + * 3. 正文引用的本地图片/资源必须真实存在; + * 4. draft: true、未来日期、TODO 占位等可疑情况给出告警。 + * + * 用法: + * node tools/check-posts.mjs # 校验相对基线的本次改动(CI 默认) + * node tools/check-posts.mjs --base= # 指定比较基线,如 origin/main + * node tools/check-posts.mjs --all # 校验全部文章(本地自查) + * + * 退出码:0 = 通过(可能有告警),1 = 有错误。 + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const POSTS_DIR = 'source/_posts'; +const SOURCE_DIR = 'source'; +const REQUIRED_FIELDS = ['title', 'date', 'tags']; +const POST_FILE_RE = /^[a-z0-9][a-z0-9._-]*\.md$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/; +const TRIVIAL_RE = /\bTODO\b|\bFIXME\b|\bWIP\b|待补充|待完善|占位/; + +const problems = []; +const notes = []; + +function add(level, file, line, message) { + problems.push({ level, file, line, message }); +} + +function stripQuotes(value) { + const v = String(value || '').trim(); + if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) { + return v.slice(1, -1).trim(); + } + return v; +} + +function asList(field) { + const items = []; + if (!field) return items; + const raw = field.value; + if (raw.startsWith('[') && raw.endsWith(']')) { + for (const part of raw.slice(1, -1).split(',')) { + const v = stripQuotes(part); + if (v) items.push(v); + } + } + for (const v of field.list) { + if (v) items.push(v); + } + return items; +} + +function parseFrontMatter(lines, file) { + if (!lines.length || lines[0].trim() !== '---') { + add('error', file, 1, '缺少 front-matter:文件第一行必须是 ---'); + return null; + } + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + end = i; + break; + } + } + if (end === -1) { + add('error', file, 1, 'front-matter 缺少结束标记 ---'); + return null; + } + + const fields = {}; + let currentKey = null; + for (let i = 1; i < end; i++) { + const raw = lines[i]; + if (raw.trim() === '' || /^\s*#/.test(raw)) continue; + const pair = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw); + if (pair) { + currentKey = pair[1]; + fields[currentKey] = { value: pair[2].trim(), line: i + 1, list: [] }; + continue; + } + const item = /^\s*-\s+(.*)$/.exec(raw); + if (item && currentKey) { + const v = stripQuotes(item[1]); + if (v) fields[currentKey].list.push(v); + } + } + return { fields, bodyStart: end + 1 }; +} + +function checkLocalAssets(file, body) { + const refs = []; + const mdRe = /!\[[^\]]*\]\(\s*([^)\s]+)/g; + const htmlRe = /]*\ssrc=["']([^"']+)["']/gi; + let m; + while ((m = mdRe.exec(body)) !== null) refs.push(m[1]); + while ((m = htmlRe.exec(body)) !== null) refs.push(m[1]); + + for (const ref of refs) { + const target = ref.split(/[?#]/)[0]; + if (!target) continue; + if (/^(?:https?:)?\/\//i.test(target) || /^(?:data|mailto|tel):/i.test(target)) continue; + if (target.startsWith('#') || target.includes('{{') || target.includes('<%')) continue; + let decoded = target; + try { + decoded = decodeURIComponent(target); + } catch { + /* 保留原样 */ + } + const candidates = decoded.startsWith('/') + ? [path.join(SOURCE_DIR, decoded), path.join(SOURCE_DIR, decoded.replace(/^\/+/, ''))] + : [path.join(path.dirname(file), decoded), path.join(SOURCE_DIR, decoded)]; + if (!candidates.some((p) => existsSync(p))) { + add('error', file, null, `引用的本地资源不存在:${ref}`); + } + } +} + +function checkPost(file) { + const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); + const lines = raw.split(/\r?\n/); + + const base = path.basename(file); + if (!POST_FILE_RE.test(base)) { + add('error', file, 1, `文件名不规范:${base}(要求全小写 slug,如 my-new-post.md 或 2026-10-14-my-new-post.md)`); + } + + const fm = parseFrontMatter(lines, file); + if (!fm) return; + const { fields, bodyStart } = fm; + + for (const key of REQUIRED_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(fields, key)) { + add('error', file, 1, `front-matter 缺少必填字段:${key}`); + } + } + + if (fields.title) { + const title = stripQuotes(fields.title.value); + if (!title) add('error', file, fields.title.line, 'title 不能为空'); + } + + if (fields.date) { + const value = stripQuotes(fields.date.value); + if (!DATE_RE.test(value)) { + add('error', file, fields.date.line, `date 格式不正确:${value}(建议 YYYY-MM-DD HH:mm:ss)`); + } else { + const parsed = new Date(value.replace(' ', 'T')); + if (Number.isNaN(parsed.getTime())) { + add('error', file, fields.date.line, `date 无法解析:${value}`); + } else if (parsed.getTime() > Date.now() + 24 * 3600 * 1000) { + add('warning', file, fields.date.line, `date 是未来时间(${value})——Hexo 会在该时间之后才发布这篇文章,确认是有意为之吗?`); + } + } + } + + if (fields.tags) { + if (asList(fields.tags).length === 0) { + add('error', file, fields.tags.line, 'tags 不能为空,至少写一个标签,如 tags: [随笔]'); + } + } + + for (const key of ['draft', 'published']) { + if (!fields[key]) continue; + const value = stripQuotes(fields[key].value).toLowerCase(); + if ((key === 'draft' && value === 'true') || (key === 'published' && value === 'false')) { + add('warning', file, fields[key].line, `${key}: ${value}——这篇文章不会被发布,如果本意是发布新文章请去掉该字段`); + } + } + + const body = lines.slice(bodyStart).join('\n').trim(); + if (body.length < 30) { + add('error', file, bodyStart, '正文内容为空或过短(少于 30 个字符)'); + } + if (TRIVIAL_RE.test(body)) { + add('warning', file, null, '正文里出现 TODO/FIXME/待补充 等占位字样,确认不是未完成的草稿'); + } + + checkLocalAssets(file, body); +} + +function walk(dir, out) { + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, out); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(full.split(path.sep).join('/')); + } + } + return out; +} + +function listAllPosts() { + return walk(POSTS_DIR, []); +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function changedPosts(base) { + const out = git(['diff', '--name-only', '--diff-filter=ACMR', base, 'HEAD']); + return out.split('\n').map((s) => s.trim()).filter(Boolean); +} + +function resolveTargets(argv) { + const all = argv.includes('--all'); + const baseArg = argv.find((a) => a.startsWith('--base=')); + const explicit = baseArg ? baseArg.slice('--base='.length) : null; + + if (all) return { files: listAllPosts(), scope: '全部文章' }; + + const bases = []; + if (explicit) bases.push(explicit); + bases.push('origin/main', 'HEAD~1'); + + let changed = null; + let used = null; + for (const b of bases) { + try { + changed = changedPosts(b); + used = b; + break; + } catch { + /* 基线不可用,换下一个 */ + } + } + if (changed === null) { + notes.push('无法确定比较基线,改为校验全部文章'); + return { files: listAllPosts(), scope: '全部文章(降级)' }; + } + + const files = changed.filter((f) => f.startsWith(`${POSTS_DIR}/`) && f.endsWith('.md')); + return { files, scope: `相对 ${used} 的改动(共 ${changed.length} 个文件,其中文章 ${files.length} 篇)` }; +} + +function main() { + const { files, scope } = resolveTargets(process.argv.slice(2)); + console.log(`check-posts: ${scope}`); + + if (files.length === 0) { + console.log('check-posts: 本次没有文章改动,跳过。'); + for (const n of notes) console.log(`::notice::${n}`); + return 0; + } + + for (const file of files) { + const before = problems.length; + checkPost(file); + const found = problems.length - before; + console.log(` - ${file}${found ? `(${found} 条问题)` : '(通过)'}`); + } + + console.log(''); + const errors = problems.filter((p) => p.level === 'error'); + for (const p of problems) { + const where = p.line ? `file=${p.file},line=${p.line}` : `file=${p.file}`; + console.log(`::${p.level} ${where}::${p.message}`); + } + for (const n of notes) console.log(`::notice::${n}`); + + const warnings = problems.filter((p) => p.level === 'warning'); + console.log(''); + console.log(`check-posts: 检查 ${files.length} 篇文章,错误 ${errors.length} 个,告警 ${warnings.length} 个。`); + if (errors.length > 0) { + console.log('check-posts: 校验失败——请先修好上面标出的错误,审查人不会批准带错误的文章。'); + return 1; + } + console.log('check-posts: 校验通过。'); + return 0; +} + +process.exit(main()); \ No newline at end of file diff --git a/tools/check-pr.mjs b/tools/check-pr.mjs new file mode 100644 index 0000000..f795854 --- /dev/null +++ b/tools/check-pr.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/* + * 校验 PR 描述里的「发布审查清单」是否逐条勾选。 + * + * 清单条目来自 .github/pull_request_template.md 的「发布审查清单」小节, + * 「必须全部勾选」这件事由本脚本强制(CI job: pr-checklist)。 + * 要改清单就得改模板,而改模板本身也要走 PR 审查,所以无法绕过。 + */ +import { readFileSync } from 'node:fs'; + +const TEMPLATE = '.github/pull_request_template.md'; +const SECTION_RE = /^#{2,4}\s*发布审查清单\s*$/; +const HEADING_RE = /^#{2,4}\s+\S/; +const ITEM_RE = /^\s*[-*]\s*\[( |x|X)\]\s*(.+?)\s*$/; + +function normalize(text) { + return String(text || '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +} + +function checklistItems(template) { + const lines = template.replace(/\r\n?/g, '\n').split('\n'); + const start = lines.findIndex((l) => SECTION_RE.test(l)); + if (start === -1) return null; + const items = []; + for (let i = start + 1; i < lines.length; i++) { + if (HEADING_RE.test(lines[i])) break; + const m = ITEM_RE.exec(lines[i]); + if (m) items.push(normalize(m[2])); + } + return items; +} + +function main() { + const author = (process.env.PR_AUTHOR || '').trim(); + const body = process.env.PR_BODY || ''; + + if (/\[bot\]$/i.test(author)) { + console.log(`::notice::PR 作者是机器人(${author}),跳过清单校验。`); + return 0; + } + + const expected = checklistItems(readFileSync(TEMPLATE, 'utf8')); + if (!expected || expected.length === 0) { + console.log('::error file=.github/pull_request_template.md::模板里找不到「发布审查清单」小节,无法校验。'); + return 1; + } + + if (!normalize(body)) { + console.log('::error::PR 描述是空的——请按模板填写说明并勾选「发布审查清单」。'); + return 1; + } + + const checked = new Set(); + for (const line of body.replace(/\r\n?/g, '\n').split('\n')) { + const m = ITEM_RE.exec(line); + if (m && m[1].toLowerCase() === 'x') checked.add(normalize(m[2])); + } + + const missing = expected.filter((item) => !checked.has(item)); + if (missing.length === 0) { + console.log(`pr-checklist: 清单 ${expected.length} 项全部勾选。`); + return 0; + } + + console.log(`::error::「发布审查清单」还有 ${missing.length} 项没有勾选,不能合并:`); + for (const item of missing) console.log(`::error::未勾选:${item}`); + return 1; +} + +process.exit(main()); \ No newline at end of file diff --git a/tools/publish-summary.mjs b/tools/publish-summary.mjs new file mode 100644 index 0000000..febc79a --- /dev/null +++ b/tools/publish-summary.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/* + * 发布摘要:把「这次到底会发布什么」写进 GitHub Actions 的 Summary, + * 让发布这个动作是「看着清单点下去的」,而不是盲点。 + * + * 用法:node tools/publish-summary.mjs [commit-ish](默认 HEAD) + * 找不到上一个 publish-* 标签时,按「首次发布」处理。 + */ +import { execFileSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +const POSTS_DIR = 'source/_posts'; +const target = process.argv[2] || 'HEAD'; +const MAX_COMMITS = 30; + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function tryGit(args) { + try { + return git(args); + } catch { + return null; + } +} + +if (!tryGit(['rev-parse', '--verify', 'HEAD'])) { + console.log('::notice::读不到 git 信息(git 不在 PATH 或不是 git 仓库),跳过发布摘要。'); + process.exit(0); +} + +const lines = []; +const emit = (line) => { + lines.push(line); +}; + +emit('## 本次发布内容'); +emit(''); + +const subject = tryGit(['log', '-1', '--pretty=%s', target]); +const when = tryGit(['log', '-1', '--pretty=%ad', '--date=iso', target]); +const who = tryGit(['log', '-1', '--pretty=%an', target]); +const sha = tryGit(['rev-parse', '--short', target]); + +if (sha) { + emit(`- 目标提交:\`${sha}\`(${when},${who})`); + emit(`- 提交说明:${subject}`); + emit(''); +} + +const tags = tryGit(['tag', '--list', 'publish-*', '--sort=-creatordate']); +const lastTag = tags ? tags.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null; + +function listPosts(ref) { + const out = tryGit(['ls-tree', '-r', '--name-only', ref, POSTS_DIR]); + if (!out) return []; + return out.split('\n').map((s) => s.trim()).filter((f) => f.endsWith('.md')); +} + +function postTitle(ref, file) { + const body = tryGit(['show', `${ref}:${file}`]); + if (!body) return null; + const m = /^title:\s*(.+)$/m.exec(body); + return m ? m[1].trim().replace(/^["']|["']$/g, '') : null; +} + +if (!lastTag) { + const posts = listPosts(target); + emit('> ⚠️ 没有找到 `publish-*` 标签,按**首次发布**处理。'); + emit(''); + emit(`本次将发布全部 **${posts.length}** 篇文章:`); + emit(''); + for (const file of posts) { + const title = postTitle(target, file); + emit(`- ${title ? `**${title}**` : file} \`${file}\``); + } +} else { + emit(`> 与上次发布 \`${lastTag}\` 比较。`); + emit(''); + const range = `${lastTag}..${target}`; + const commits = tryGit(['log', '--pretty=format:%h %ad %s', '--date=short', range]); + + if (commits === null) { + emit('- 无法比较历史(可能是浅克隆),请人工核对 diff。'); + } else if (commits === '') { + emit('- 与上次发布相比**没有新提交**——如果这是预期行为(重新构建)可以继续。'); + } else { + const list = commits.split('\n'); + emit(`本次包含 **${list.length}** 个提交:`); + emit(''); + emit('```'); + for (const c of list.slice(0, MAX_COMMITS)) emit(c); + if (list.length > MAX_COMMITS) emit(`...(还有 ${list.length - MAX_COMMITS} 个提交未列出)`); + emit('```'); + + const changed = tryGit(['diff', '--name-status', range]) || ''; + const posts = []; + const others = []; + for (const row of changed.split('\n').map((s) => s.trim()).filter(Boolean)) { + const [status, ...rest] = row.split('\t'); + const file = rest.join('\t'); + if (file.startsWith(`${POSTS_DIR}/`) && file.endsWith('.md')) { + posts.push({ status: status.charAt(0), file }); + } else if (file) { + others.push(file); + } + } + + emit(''); + if (posts.length === 0) { + emit('- 文章本身没有变化(可能只是改了主题/配置)。'); + } else { + emit(`文章改动 **${posts.length}** 篇:`); + emit(''); + for (const p of posts) { + const label = p.status === 'D' ? '删除' : p.status === 'A' ? '新增' : '修改'; + const title = p.status === 'D' ? null : postTitle(target, p.file); + emit(`- [${label}] ${title ? `**${title}**` : p.file} \`${p.file}\``); + } + } + if (others.length > 0) { + emit(''); + emit(`其他文件改动 **${others.length}** 个(主题、配置、工作流等)。`); + } + } +} + +emit(''); +emit('---'); +emit(''); +emit('确认无误就继续走 `build` → `deploy`;发现问题可以直接取消这次运行,线上不会变。'); + +const markdown = `${lines.join('\n')}\n`; + +console.log(markdown); + +if (process.env.GITHUB_STEP_SUMMARY) { + try { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown, 'utf8'); + } catch (err) { + console.log(`::warning::写入 step summary 失败:${err.message}`); + } +} \ No newline at end of file