feat(skill): 新增 dootask-release 发版技能

翻译与更新日志在技能内直接产出,版本号计算/差异检测/语言文件生成等
机械步骤交给本地脚本(language.php、version_bump.js,host 直跑、不进容器)。
language.php 用 php 以字节级对齐项目原生产物;脚本相对自身定位项目根,与 cwd 无关。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-03 07:19:23 +00:00
parent 36da18af79
commit 035c9d9d3d
3 changed files with 430 additions and 51 deletions

View File

@ -1,6 +1,6 @@
---
name: dootask-release
description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程 translate → version → build → commit → push前置检查 + 每步确认、失败即停。
description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。
---
# DooTask 发布流程
@ -9,7 +9,7 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程
## 核心原则
**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并或重排步骤
按固定顺序执行不增删、合并或重排步骤。翻译Step 1和更新日志Step 2由你直接产出脚本只做确定性机械工作算版本号、检测差异、字节级生成语言文件
## 前置检查(全部通过才能继续)
@ -17,7 +17,8 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程
1. **分支**:必须是 `pro`,否则停止,提示用户切换
2. **工作区**`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理
3. **Node.js**:必须 ≥ 20否则停止
3. **Node.js**`node --version` 必须 ≥ 20
4. **PHP**`php --version` 必须可用Step 1 的脚本依赖本地 php无需容器。若 host 无 php停止并提示用户
检查通过后汇报结果,用户确认后再开始执行。
@ -25,29 +26,140 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程
**每步执行前**向用户确认;**每步执行后**报告结果。
### Step 1: 翻译
```shell
npm run translate
```
更新多语言翻译文件。
开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度:
### Step 2: 版本号
```shell
npm run version
```
更新版本号。
发布进度:
- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用)
- [ ] Step 1 翻译diff → 翻译 → apply → generate
- [ ] Step 2 版本号 + CHANGELOG
- [ ] Step 3 构建(./cmd prod
- [ ] 汇总变更 → 用户确认 → commit + push
- [ ] 确认 GitHub Actions Publish 工作流 success
```
---
### Step 1: 翻译
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`
**1.1 检测差异**
```shell
php .claude/skills/dootask-release/scripts/language.php diff
```
输出 JSON
- `regexErrorCount > 0`translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
- `redundantCount > 0`translate.json 里有、但原文已删除的条目 → 仅作提示apply 时会自动剔除,不致命)
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
**1.2 翻译**
`needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
| 原文 | 翻成英语 |
|---|---|
| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] |
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru``zh``""`)。
```json
[
{"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."}
]
```
**1.3 合并进 translate.json**
```shell
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
```
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。
**1.4 生成前端/后端语言文件**
```shell
php .claude/skills/dootask-release/scripts/language.php generate
```
`translate.json` 字节级重新生成 `public/language/web/*.js``public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。
**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。
---
### Step 2: 版本号 + 更新日志
**2.1 计算并写入版本号**
```shell
node .claude/skills/dootask-release/scripts/version_bump.js
```
脚本据 git 历史算出新 `version``codeVerson` 并写入 `package.json`,输出 JSON 含:`version``prevVersion``changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。
**2.2 撰写 CHANGELOG**
读取本次区间的提交:
```shell
git log <changelogRange> --stat
```
`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show <hash>` 看具体代码改动。
`CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段:
```markdown
## [<version>]
### Features
- ...
### Bug Fixes
- ...
### Performance
- ...
```
撰写要求(对齐项目历史风格):
- 小节标题用**英文 Title Case**`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`**不要译成中文****没有内容的小节整段省略**。
- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。
- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。
- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show <hash>`)再决定,不要只看一行就下结论。
- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。
**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。
---
### Step 3: 构建前端
```shell
npm run build
```
构建前端生产版本。
> **已知失败**:若 build 报 `public/uploads/...``EACCES: permission denied, copyfile`,是 vite 复制 `public/` 目录时碰到运行时残留的上传文件——这些文件常为 **root 属主**(容器内 root 进程写入),复制需覆盖写入,构建用户没有写权限。报错路径可能落在 `public/uploads` 下任意子目录(`tmp``avatar` 等),不限于 `tmp`。这不是代码问题。**补救(赋权,不删数据)**:把整个 uploads 的属主改回当前用户后重试 build
```shell
./cmd prod
```
构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite它还负责 node 检查、清 `public/js/build`、debug 切换)。
> **已知失败**build 报 `public/uploads/...``EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp``avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试:
> ```shell
> sudo chown -R "$(id -u):$(id -g)" public/uploads
> ```
> 需 root本机可免密 sudo或经 docker 以 root 改权限)。**优先赋权,不要删**——`public/uploads` 含真实上传数据。即便用户要求清理,也只清临时目录 `public/uploads/tmp`,切勿删 uploads 下其他内容。
> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`
---
## 最终:提交并推送
@ -59,8 +171,10 @@ npm run build
4. 未确认一律不执行
提交规范:
- 提交信息使用 `release: v<新版本号>`(与历史提交风格一致,参见 `git log --oneline | grep '^release:'`
- **只 add 本次发布相关改动**,按文件名显式添加(例如 `git add package.json public/js/...`**不要用 `git add -A``git add .`**,以免卷入未跟踪的本地实验文件
- 提交信息使用 `release: v<新版本号>`(与历史一致,参见 `git log --oneline | grep '^release:'`
- **只 add 本次发布相关改动**,按文件名/目录显式添加(例如 `git add package.json CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件
- 不打 git tag现行发布流程不使用 tag
- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件
## push 之后确认发布工作流CI 才是真正出包)
@ -74,38 +188,17 @@ push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成
```
- 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。
### 可选:iOS 发布
### iOS 发布(询问后决定)
`ios-publish.yml` 是**独立的手动工作流**`workflow_dispatch`),不随 push 触发。**仅当用户明确要求**发 iOS 时执行:
```shell
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
```
`gh` 已登录且 token 含 `workflow` 权限。触发后同样可挂后台轮询其结果。
`ios-publish.yml` 是**独立的手动工作流**`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS选项发布 iOS / 不发布):
- 选「发布 iOS」才执行
```shell
gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask
```
`gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。
- 选「不发布」则结束。
## 失败处理
- 任何步骤失败立即停止,报告错误信息
- **不要**自动重试
- **不要**自动跳过失败步骤
- 由用户决定如何处理
## 禁止项(基线测试暴露的反模式)
| 错误做法 | 正确做法 |
|---------|---------|
| 遇到脏工作区主动提出修复方案(加 `.gitignore`、先 push 等) | **停下**,报告脏工作区事实,交用户决定 |
| 增加 `git tag v1.7.xx` 步骤 | DooTask 现行发布流程**不打 tag**,不要擅自添加 |
| `git add -A` / `git add .` | 按文件名显式添加发布相关改动 |
| 一次性 add + commit + push不给确认机会 | 摘要 → 问确认 → 再 add/commit/push 三步分离 |
| 把 translate/version/build 顺序自作主张调整 | 顺序固定为 translate → version → build |
| 失败后"我再试一次"或"跳过这步" | 立即停止,交还给用户 |
## Red Flags —— 出现这些念头立即停下
- "这个脏工作区我来帮 TA 搞定一下" → 停下,交用户
- "顺便打个 tag 吧" → 不,没有这一步
- "`git add -A` 省事" → 不,显式 add
- "翻译这步没改动可以跳" → 不,按顺序执行、执行后报告结果即可
- "一起 commit + push 一气呵成" → 必须先让用户确认
- "push 上去了,发布就完成了" → 不push 只是触发器,要确认 GitHub Actions 的 Publish 工作流 success
- "build 报 uploads 权限错,我直接删掉" → 优先 `chown` 赋权整个 `public/uploads`(不丢数据);真要删也只删 `tmp`,别碰 uploads 下真实上传数据
任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。

View File

@ -0,0 +1,239 @@
<?php
// DooTask 发布——翻译流水线(纯本地 phphost 直接跑,不进容器、不调 OpenAI、不需 autoload
// 逐行对齐 language/translate.php 的检测/保存/生成逻辑,唯独把"调用外部模型翻译"那一段抽走,
// 翻译改在技能流程内完成。用 php 而非 node 的唯一原因array_multisort + json_encode
// 的逐字节产物必须与项目原生工具一致,否则每次发版都会产生大面积排序/转义噪声 diff已验证 host php 可字节级复现)。
//
// 子命令:
// language.php diff
// —— 输出 JSONneeds(待翻译key 已转成 (%T1)/(%M1) 形式) / redundants(冗余,提示) / regexErrors(占位符错乱,致命)
// language.php apply <translated.json>
// —— 把新翻译合并进 translate.json追加 + 剔除冗余),不生成 public 文件
// language.php generate
// —— 由 translate.json 重新生成 public/language/{web,api}/*
//
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
$ROOT = dirname(__DIR__, 4);
$LANG_DIR = $ROOT . '/language';
$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'];
if (!is_dir($LANG_DIR)) {
fwrite(STDERR, "未找到 language 目录($LANG_DIR。\n");
exit(1);
}
chdir($LANG_DIR);
$cmd = $argv[1] ?? '';
// ---- 公共:读取 original-*.txt ----
function read_generateds(): array
{
$originals = [];
$generateds = [];
foreach (['web', 'api'] as $type) {
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
$array = array_values(array_filter(array_unique(explode("\n", $content))));
$generateds[$type] = $array;
$originals = array_merge($originals, $array);
}
return [$originals, $generateds];
}
// ---- 公共:构建 translations 映射normalizedKey -> obj并收集冗余/占位符错乱 ----
function build_translations(array $originals): array
{
$translations = [];
$redundants = [];
$regrror = [];
if (!file_exists("translate.json")) {
fwrite(STDERR, "translate.json not exists\n");
exit(1);
}
$tmps = json_decode(file_get_contents("translate.json"), true);
foreach ($tmps as $obj) {
if (!isset($obj['key'])) {
continue;
}
$currentKey = $obj['key'];
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
if (!in_array($originalKey, $originals)) {
$redundants[$originalKey] = $obj;
continue;
}
$translations[$originalKey] = $obj;
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
foreach ($matches[0] as $match) {
foreach ($obj as $k => $v) {
if (empty($v)) {
continue;
}
if (!str_contains($v, $match)) {
$regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match];
continue 2;
}
}
}
}
}
return [$translations, $redundants, $regrror];
}
// ---- 公共:由 translate.json + originals 重新生成 public 文件 ----
function generate(array $generateds, array $translations): void
{
foreach ($generateds as $type => $array) {
$datas = [];
foreach ($array as $text) {
$text = trim($text);
if (isset($translations[$text])) {
$datas[] = $translations[$text];
}
}
$inOrder = [];
foreach ($datas as $index => $item) {
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
$inOrder[$index] = strlen($item['key']);
} else {
$inOrder[$index] = strlen($item['key']) + 10000000000;
}
}
array_multisort($inOrder, SORT_DESC, $datas);
$results = [];
foreach ($datas as $items) {
foreach ($items as $kk => $item) {
$results[$kk][] = $item;
}
}
if ($type === 'api') {
if (!is_dir("../public/language/api")) {
mkdir("../public/language/api", 0777, true);
}
foreach ($results as $kk => $item) {
file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE));
}
} elseif ($type === 'web') {
if (!is_dir("../public/language/web")) {
mkdir("../public/language/web", 0777, true);
}
foreach ($results as $kk => $item) {
file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
}
}
echo "[$type] total: " . count($results['key']) . "\n";
}
}
if ($cmd === 'diff') {
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regrror] = build_translations($originals);
// 需要翻译的数据(对齐 translate.php 150-169占位符按单一计数器编号
$needs = [];
foreach ($originals as $text) {
$key = trim($text);
if ($key === '') {
continue;
}
if (!isset($translations[$key])) {
$needs[$key] = $key;
}
}
$needsOut = [];
foreach ($needs as $key) {
$c = 1;
$converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
$label = strlen($m[1]) > 1 ? "M" : "T";
return "(%" . $label . $c++ . ")";
}, $key);
$needsOut[] = ['key' => $converted];
}
echo json_encode([
'needsCount' => count($needsOut),
'redundantCount' => count($redundants),
'regexErrorCount' => count($regrror),
'needs' => $needsOut,
'redundants' => array_keys($redundants),
'regexErrors' => array_values($regrror),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
if (count($regrror) > 0) {
exit(2); // 已有数据占位符错乱,需先修复
}
exit(0);
}
if ($cmd === 'apply') {
$file = $argv[2] ?? '';
if ($file === '' || !file_exists($file)) {
fwrite(STDERR, "用法apply <translated.json>(文件不存在)\n");
exit(1);
}
[$originals, $generateds] = read_generateds();
[$translations, $redundants, $regrror] = build_translations($originals);
if (count($regrror) > 0) {
fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n");
exit(2);
}
$incoming = json_decode(file_get_contents($file), true);
if (!is_array($incoming)) {
fwrite(STDERR, "translated.json 必须是数组\n");
exit(1);
}
$added = 0;
foreach ($incoming as $raw) {
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
if (!array_key_exists($f, $raw)) {
fwrite(STDERR, "新翻译缺字段 \"$f\"" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n");
exit(1);
}
}
// 占位符完整性key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里
if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) {
foreach ($m[0] as $match) {
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
if ($f === 'key' || $f === 'zh') {
continue;
}
if (empty($raw[$f])) {
continue;
}
if (!str_contains($raw[$f], $match)) {
fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n");
exit(1);
}
}
}
}
// 规范化:固定字段顺序 + zh 置空
$item = [];
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
$item[$f] = $f === 'zh' ? '' : $raw[$f];
}
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']);
$translations[$originalKey] = $item;
$added++;
}
// array_values现有条目去冗余在前新条目追加在后
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
echo json_encode([
'added' => $added,
'total' => count($translations),
'droppedRedundant' => count($redundants),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
exit(0);
}
if ($cmd === 'generate') {
[$originals, $generateds] = read_generateds();
[$translations] = build_translations($originals);
generate($generateds, $translations);
exit(0);
}
fwrite(STDERR, "未知子命令:'$cmd'。可用diff | apply <file> | generate\n");
exit(1);

View File

@ -0,0 +1,47 @@
#!/usr/bin/env node
// 计算并写入新版本号到 package.jsonversion + codeVerson算法对齐 bin/version.js。
// 不生成 CHANGELOG在技能流程内撰写只输出版本号与 changelog 的提交区间。
//
// 项目根相对脚本自身定位(脚本固定在 <root>/.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.resolve(__dirname, '../../../..');
const pkgFile = path.join(ROOT, 'package.json');
const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致)
const codeOffset = 35; // 代码版本号偏移量
function git(cmd) {
return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim();
}
const verCount = parseInt(git('git rev-list --count HEAD'), 10);
const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10);
const num = verOffset + verCount;
if (Number.isNaN(num)) {
console.error(`版本计算失败rev-list count=${verCount}`);
process.exit(1);
}
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
const codeVersion = codeOffset + codeCount;
let pkg = fs.readFileSync(pkgFile, 'utf8');
const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || '';
pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
fs.writeFileSync(pkgFile, pkg, 'utf8');
// 上一个 release 提交作为 changelog 区间下界
let prevReleaseCommit = '';
try {
prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H");
} catch (e) { /* ignore */ }
console.log(JSON.stringify({
version,
codeVersion,
prevVersion,
prevReleaseCommit,
changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)',
}, null, 2));