name: Release Prep # 发布准备自动化:bump 版本号(package.json + package-lock)+ 同步双 README 测试徽章 # + CHANGELOG 顶部空节 + 双 README 版本表占位行与路线图状态回填,推 release-prep/<版本> 分支并自动开 PR。 # # 分工:机械的归机器,判断的归人。脚本只写「由版本号唯一确定」的东西(版本号、测试数、 # 占位行、路线图状态格);CHANGELOG 条目、版本表主题描述、根 README 的 News 头条都留给人在 # PR 里补——机器写内容只会写出没人信的话。 # # SECURITY.md 的支持版本表只核对、不改写:跨 minor 发版时整张表要向下挪一格(含停止维护 # 通知那段),那是维护策略的判断,写错就是「0.9.x 已停止支持」这种真事故。表没跟上时脚本 # 只警告、不中断,PR 照开——硬失败会让跨 minor 发版彻底走不通(表先改则 package.json 还旧、 # 测试红;版本号先改则表落后、测试红),人就会绕过这套流程手工发版,那才是最坏结果。 # 代价是那个 PR 的 CI 一开始就是红的,直到人把表挪完——这正是 test/security-doc.test.js 该红的时候。 # # 使用:手动触发填目标版本号 → PR 里补三处人写内容 → 合并 → 推 v<版本> tag 即自动发布(release.yml)。 on: workflow_dispatch: inputs: version: description: '目标版本号(如 0.9.1,不带 v)' required: true permissions: contents: write pull-requests: write jobs: prep: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Validate version format env: VERSION: ${{ inputs.version }} run: | if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "::error::版本号格式应为 0.X.Y 或 0.X.Y-beta.N(如 0.9.1 / 0.14.0-beta.1),当前: $VERSION" exit 1 fi # 本工作流要推分支、开 PR,默认凭据必须留下——artipacked 在此是唯一合理豁免 - uses: actions/checkout@v7 # zizmor: ignore[artipacked] - uses: actions/setup-node@v7 with: node-version: 24 cache: npm cache-dependency-path: dsh-mneme/package-lock.json - name: Install dependencies working-directory: dsh-mneme run: npm ci # 取套件总数(`tests N`)而不是通过数(`pass N`):徽章报的是 CI(ubuntu)口径, # 平台条件跳过的用例在 pass 里不计、在 tests 里计入。用 pass 的话,本地或 # 条件不同的机器上跑这个工作流会写进一个比 badge:sync 小 1 的数——两条路径 # 各有一套记账口径,正是 v0.8.11 徽章漂移的同类失效。 - name: Count tests id: tests working-directory: dsh-mneme run: | COUNT=$(node --test test/*.test.js 2>&1 | tail -10 | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+') echo "count=$COUNT" echo "count=$COUNT" >> "$GITHUB_OUTPUT" - name: Run release-prep (version bump + badge + placeholder rows) env: # inputs / steps.outputs 内插进 run 都是 zizmor 的 template-injection 反模式 # (与下面 VERSION 走 env 同一个理由):env 注入的值在 shell 里是普通变量, # 不重解析。version 虽有上一步的正则卡格式,仍走 env 让扫描器零推理。 VERSION: ${{ inputs.version }} TEST_COUNT: ${{ steps.tests.outputs.count }} run: node scripts/release-prep.mjs "$VERSION" "$TEST_COUNT" - name: Create release-prep branch + PR env: GH_TOKEN: ${{ github.token }} # 版本号走环境变量而不是直接内插进 run:`${{ inputs.version }}` 出现在 run 里会被 # zizmor 判成 template-injection(模板展开可能带进攻击者可控的代码)。虽然上面那道 # Validate 已经用 ^[0-9]+\.[0-9]+\.[0-9]+$ 卡过格式,但让静态扫描器看清这一点 # 比让它去推理「上游校验是否够严」便宜得多。env 注入的值在 shell 里是普通变量,不重解析。 VERSION: ${{ inputs.version }} run: | git config user.name "modusensus" git config user.email "modusensus@users.noreply.github.com" if [ -z "$(git status --porcelain)" ]; then echo "::warning::无改动(版本号/占位行已存在)——跳过提交与 PR" exit 0 fi BRANCH="release-prep/$VERSION" REMOTE_HEAD=$(git ls-remote --heads origin "$BRANCH" | cut -f1) FORCE="" if [ -n "$REMOTE_HEAD" ]; then OPEN_PR=$(gh pr list --head "$BRANCH" --state open --json number --jq ".[0].number" || true) if [ -n "$OPEN_PR" ]; then echo "::warning::分支 $BRANCH 上已有进行中的 PR #$OPEN_PR——重跑会盖掉人在 PR 里补的条目,本次不动任何分支。" echo "去补 #$OPEN_PR 里的三处人写内容,或关掉它之后重跑。" exit 0 fi git fetch --depth=1 origin "$BRANCH" >/dev/null 2>&1 HEAD_SUBJECT=$(git log -1 --format=%s FETCH_HEAD) HEAD_AUTHOR=$(git log -1 --format="%an <%ae>" FETCH_HEAD) # 只在「顶端是上次机器产出」时才当作重跑覆盖:人工推过东西的分支一律停下。 # case 的模式里 $VERSION 会展开,用 case 而不是 ${var#pat} 免得模式里的 # 空格与全角括号在转义上出岔子。 MACHINE_TAIL=no case "$HEAD_SUBJECT" in "chore: v$VERSION 发布准备"*) if [ "$HEAD_AUTHOR" = "modusensus " ]; then MACHINE_TAIL=yes fi ;; esac if [ "$MACHINE_TAIL" != "yes" ]; then echo "::error::分支 $BRANCH 已存在、没有进行中的 PR,但它的顶端提交不是本工作流生成的($HEAD_AUTHOR / $HEAD_SUBJECT)。" echo "先人工确认那条分支上的内容——合并或删掉它——再重跑,避免覆盖掉别人的提交。" exit 1 fi echo "分支 $BRANCH 已存在,顶端是上次机器产出、且没有 PR(多半卡在开 PR 那一步):按重跑处理,覆盖它。" FORCE="--force-with-lease=refs/heads/$BRANCH:$REMOTE_HEAD" fi git checkout -b "$BRANCH" git add dsh-mneme/package.json dsh-mneme/package-lock.json dsh-mneme/CHANGELOG.md README.md dsh-mneme/README.md { echo "chore: v$VERSION 发布准备(版本号 + 测试徽章 + CHANGELOG/版本表占位行)" echo echo "机械部分已由 scripts/release-prep.mjs 完成。合并前还需要人来写:" echo echo "- [ ] dsh-mneme/CHANGELOG.md 新节里的条目(现在只有一条「- (待填)」)" echo "- [ ] 两个 README 里新占位行的主题描述" echo "- [ ] 根 README 的「📰 最近更新」/「📰 Recent updates」段落的头条(现在是标题式散文,不是表格)" echo "- [ ] 跨 minor 时确认 README 路线图上新 minor 行的主题(占位行只写了「(待填)」)" echo "- [ ] 跨 minor 时把 SECURITY.md 中英两张支持版本表挪一格(活跃开发 / 尽力维护 / 不支持三段一起挪,含停止维护那段通知)" } > commit-msg.txt git commit -F commit-msg.txt # ${FORCE:+"$FORCE"}:FORCE 为空 → 不产生参数;非空 → 单个整体参数。 # 写 $FORCE 裸引用会被 shellcheck SC2086 拦(workflow-hygiene 联动检查) git push origin "HEAD:refs/heads/$BRANCH" ${FORCE:+"$FORCE"} # PR 正文走 --body-file:多行中文塞进 --body 需要一堆 $'\n' 转义, # 读的人看不懂、改的人不敢动。写成文件既好读也不受 shell 引号规则摆布。 { echo "由 Release Prep 工作流生成。**机械部分已完成**:版本号(package.json + package-lock)、测试徽章(自动取当前测试数)、CHANGELOG 顶部占位节、双 README 版本表占位行、路线图上遗留的「🚧 准备中」回填为已发布。" echo echo "**合并前还需要人来写**:" echo echo "- [ ] CHANGELOG 新节的条目(dsh-mneme/CHANGELOG.md,现在只有一条「- (待填)」)" echo "- [ ] 两个 README 里新占位行的主题描述" echo "- [ ] 根 README 的「📰 最近更新」与「📰 Recent updates」段落的头条(现在是标题式散文,不是表格)" echo "- [ ] 跨 minor 时确认路线图新 minor 行的主题(占位行只写了「(待填)」)" echo "- [ ] 跨 minor 时把 SECURITY.md 中英两张支持版本表挪一格(活跃开发 / 尽力维护 / 不支持三段一起挪,含停止维护那段通知)——不挪的话,本 PR 的 CI 会在 security-doc.test.js 上红" echo echo "合并后推 v$VERSION tag 即自动发布(release.yml 会核对 tag / package.json / CHANGELOG 三方一致)。" } > pr-body.md # GITHUB_TOKEN 能不能开 PR 取决于仓库级开关(Settings → Actions → General → # "Allow GitHub Actions to create and approve pull requests"),job 里写 # `pull-requests: write` 覆盖不了它——2026-10-18 实测这两个开关是反的, # 整条工作流的最后一步因此每次都失败,而前面七步全是好的。 if ! gh pr create --base main --head "$BRANCH" \ --title "release: v$VERSION 发布准备" \ --body-file pr-body.md; then echo "::error::分支 $BRANCH 已推上去,但开 PR 失败。若报 'GitHub Actions is not permitted to create or approve pull requests',去仓库 Settings → Actions → General 打开 'Allow GitHub Actions to create and approve pull requests',然后重跑本工作流——此时还没有 PR,重跑守卫会覆盖上次的机器产出,不会丢任何人的改动。" exit 1 fi