dsh-gokifu-compact 1.11.3 → 1.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/scripts/check-maintenance.mjs +23 -0
- package/scripts/release-lib.mjs +103 -0
- package/scripts/release.mjs +137 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.11.5 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
### S10d · 测试运行器终于能区分「跳过」与「通过」,「空壳用例」不再是绿的
|
|
6
|
+
|
|
7
|
+
**问题**:旧 `run.mjs` 里,平台/权限不对时用 `return` 早退,**会计入 passed** —— 单看
|
|
8
|
+
「N passed」根本排除不了跳过;而且一个**一条断言都没有**的用例也会静默算通过。
|
|
9
|
+
|
|
10
|
+
**改法**
|
|
11
|
+
|
|
12
|
+
| 能力 | 说明 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `t.skip(reason)` | 计入 `skipped`,不计 pass/failed;**exit code 只由 failed 决定** |
|
|
15
|
+
| 汇总行 | `== N passed, M skipped, K failed ==`(新增中间那段,跳过不再藏在 passed 里) |
|
|
16
|
+
| **零断言检测** | `assertions === 0` **且**源码里没有 `throw` / `assert` 证明 ⇒ 计入 `zeroAssertions` 并**按失败处理**。本仓 300+ 用例是 throw 风格(算有证明),空壳用例从此不再静默算 pass |
|
|
17
|
+
| `--dir=` | 仅供 `run-skip` 夹具指向临时目录 |
|
|
18
|
+
|
|
19
|
+
- **`install-link` 6 处平台/权限早退改为 `t.skip(...)`**,原有断言一字未改。
|
|
20
|
+
- 新增 `test/run-skip.test.mjs` **3 条**:四态(pass/fail/skip/零断言)可区分、skip 不导致失败、零断言严格红。
|
|
21
|
+
|
|
22
|
+
**变异自检**:删掉 skip 分支 ⇒ `run-skip` 红;删掉零断言检测 ⇒ `run-skip` 红。
|
|
23
|
+
|
|
24
|
+
### 验证
|
|
25
|
+
|
|
26
|
+
- 全量:**476 passed / 0 failed / 0 skipped**(36 个测试文件;473 + 3)
|
|
27
|
+
- `install-link` **29/0** · `run-skip` **3/0** · `rooms` 15/0 · `verify-install` **16/16**
|
|
28
|
+
- `bench 2000`:**351 tokens**(与基线一致)
|
|
29
|
+
- `npm pack --dry-run --json`:**71 文件**(数量不变,只改 `test/`)
|
|
30
|
+
|
|
31
|
+
**未验证**
|
|
32
|
+
|
|
33
|
+
- 零断言检测只认 `assertions` 计数与 `throw`/`assert` 的**文本证据**:如果一个用例用别的方式实打实地检查了东西(例如把结果写进外部文件再断言),仍可能被误判 —— 目前本仓没有这种写法。
|
|
34
|
+
- `skipped` 总数现在是 0(本机 Windows)。**跨平台**(Linux/macOS 上那 6 条会真的 skip)没有实跑过。
|
|
35
|
+
|
|
36
|
+
## 1.11.4 — 2026-10-31
|
|
37
|
+
|
|
38
|
+
### 发布脚本加固:坏 remote 在预演阶段就红,git 步骤事务化
|
|
39
|
+
|
|
40
|
+
**背景是一起真实事故**:1.11.3 发布时 `scripts/release.mjs --yes --npm` 返回 **EXIT 1** ——
|
|
41
|
+
仓库 `remote.origin.url` 被写成 `git+https://…`(那串正是 `package.json` 的 `repository.url`,
|
|
42
|
+
在 npm 里是规范写法,**但不是合法的 git remote**)。旧体检用正则匹配 `github.com:` 照样通过;
|
|
43
|
+
真做时 `git push` 全崩,**而脚本继续建 Release、继续 `npm publish`**,留下
|
|
44
|
+
「npm 已发 / Release 已建 / main 没推 / tag 指向旧 commit」的半途状态。
|
|
45
|
+
|
|
46
|
+
**新增 `scripts/release-lib.mjs`**(纯逻辑、可单测、零副作用)
|
|
47
|
+
|
|
48
|
+
| 导出 | 作用 |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `parseRepo(url)` | 只认 scp 式 `git@github.com:o/r(.git)`、`ssh://git@github.com/o/r`、`https://github.com/o/r`;`git+*`、`file://`、非 GitHub、空 → `null` |
|
|
51
|
+
| `classifyRemote(url)` | **先判 scheme 再判仓库**;`git+…` 直接拒,并给出可执行的修法(`git remote set-url origin git@github.com:<owner>/<repo>.git`) |
|
|
52
|
+
| `runTransaction(steps)` | 顺序执行 + **首步失败即中止**,返回 `{ok, done, failed, error, aborted}` |
|
|
53
|
+
|
|
54
|
+
**`scripts/release.mjs` 体检从 5 项增到 8 项**
|
|
55
|
+
|
|
56
|
+
- ⑤ **remote 必须是合法 GitHub URL**(不再「正则匹配到 github.com 就算过」)
|
|
57
|
+
- ⑥ **`git ls-remote --heads origin` 必须成功** —— 远端不可达直接红
|
|
58
|
+
- ⑦ **`git fetch` 失败从 note 改成 red** —— 旧版把「不知道远端正不正常」当成「跳过检查继续发版」
|
|
59
|
+
- ⑧ **tag 已存在时区分两种情况**:指向本 commit ⇒「不能发两次」;**指向别的 commit**(1.11.3 的现场:Release API 自动建了轻量 tag)⇒ **不自动 force**,报错并给 `git push --force origin refs/tags/<tag>:refs/tags/<tag>` 的人工修法
|
|
60
|
+
|
|
61
|
+
**真做改成事务**:`runTransaction([push 分支, tag -a && push tag])` → 任一失败即 `abort()`,
|
|
62
|
+
打印「已完成 / 未执行:tag · npm · Release」;**`npm publish` 移到 Release 之前** ——
|
|
63
|
+
npm 失败就不建 Release(避免「Release 说有、npm 上没有」);Release 失败仍 exit 1。
|
|
64
|
+
|
|
65
|
+
**`scripts/check-maintenance.mjs` 也补了两项**(日常维护就能提前发现)
|
|
66
|
+
|
|
67
|
+
- ⑧ `remote.origin` 必须是合法 GitHub URL(复用 `classifyRemote`,同一份判据)
|
|
68
|
+
- ⑨ `git rev-parse --show-toplevel` 必须等于 cwd(防串仓 —— `ROOT` 取 `process.cwd()`,
|
|
69
|
+
在子目录或别人的仓库里跑会把版本号/CHANGELOG/tag 写错对象而看起来一切正常)
|
|
70
|
+
|
|
71
|
+
**验证**
|
|
72
|
+
|
|
73
|
+
- 全量:**473 passed / 0 failed**(36 个测试文件;463 + 10 条 `release-hardening`)
|
|
74
|
+
- `check-maintenance`:**9 项**
|
|
75
|
+
- `npm pack --dry-run --json`:**71 文件**(70 + `scripts/release-lib.mjs`;`release.mjs` 运行时依赖它,必须随包)
|
|
76
|
+
- **预演**:release-ready 的临时克隆上 `node scripts/release.mjs` → **EXIT 0**;
|
|
77
|
+
同一克隆把 remote 换成 `git+https://…` → **EXIT 1**,报错点名协议并给修法
|
|
78
|
+
|
|
79
|
+
**未验证**
|
|
80
|
+
|
|
81
|
+
- 未改 `.github/workflows/release.yml`(存在时脚本走「推 tag 自动建 Release」分支,本次未触碰)。
|
|
82
|
+
- `check-maintenance` 的 ⑧ 只判 URL 形状,**不探远端可达性**(保持「不需要网络」的定位;可达性由 `release.mjs` 的 ⑥ 在发版时把关)。
|
|
83
|
+
|
|
3
84
|
## 1.11.3 — 2026-10-31
|
|
4
85
|
|
|
5
86
|
### P0-1 · `exit` 判据收紧:不再被正文子串误判
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-GOkifu-compact
|
|
2
2
|
|
|
3
|
-
> 当前版本:**`1.11.
|
|
3
|
+
> 当前版本:**`1.11.5`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
|
|
4
4
|
|
|
5
5
|
**DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
|
|
9
9
|
失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
|
|
10
10
|
清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
|
|
11
|
-
验证
|
|
11
|
+
验证 476 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gokifu-compact",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.5",
|
|
4
4
|
"description": "DSH context-compression backend with a go game record (kifu) DAG: node = position, edge = attempted move + result. Incremental fold (each span summarized once), failure memory that survives compaction, transposition detection (chess-engine style), and a tiny hot ledger rebuilt every step.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readFileSync, existsSync } from "node:fs"
|
|
19
19
|
import { join, resolve } from "node:path"
|
|
20
20
|
import { spawnSync } from "node:child_process"
|
|
21
|
+
import { classifyRemote } from "./release-lib.mjs"
|
|
21
22
|
|
|
22
23
|
const ROOT = resolve(process.argv[2] ?? ".")
|
|
23
24
|
const results = []
|
|
@@ -141,6 +142,28 @@ ok(usesAnnotated,
|
|
|
141
142
|
"用 lightweight tag ⇒ Release 页没有 tag 说明、`git cat-file -t` 只看到 commit,历史无法解释",
|
|
142
143
|
'把 scripts/release.mjs 里的 "git tag <tag>" 改成 "git tag -a <tag> -m \\"release <tag>\\""')
|
|
143
144
|
|
|
145
|
+
// ── ⑧ remote 必须是合法的 GitHub URL ─────────────────────────────────
|
|
146
|
+
// 2026-10-31 事故:`remote.origin.url` 被写成 `git+https://github.com/…`(从 package.json 的
|
|
147
|
+
// repository.url 抄来的写法)。旧判据用正则匹配 `github.com:` ⇒ 照样通过;真做时 `git push`
|
|
148
|
+
// 全崩(git 去找 remote helper `git-remote-git+https`)。这里复用 release-lib 的同一份判据,
|
|
149
|
+
// 让日常维护就能提前发现坏 remote,而不是等到发版那一刻。
|
|
150
|
+
const remoteUrl = git(["config", "--get", "remote.origin.url"]) ?? ""
|
|
151
|
+
const remoteCheck = classifyRemote(remoteUrl)
|
|
152
|
+
ok(remoteCheck.ok,
|
|
153
|
+
"remote.origin 是合法的 GitHub URL",
|
|
154
|
+
remoteCheck.ok ? "" : remoteCheck.reason,
|
|
155
|
+
"git remote set-url origin git@github.com:<owner>/<repo>.git")
|
|
156
|
+
|
|
157
|
+
// ── ⑨ 当前目录必须就是仓库根(防串仓)──────────────────────────────
|
|
158
|
+
// `ROOT` 取 `process.cwd()`:若在子目录或另一个仓库里跑,版本号 / CHANGELOG / tag
|
|
159
|
+
// 全会指向错误的对象,而输出看起来一切正常。
|
|
160
|
+
const topLevel = git(["rev-parse", "--show-toplevel"])
|
|
161
|
+
const sameRepo = !!topLevel && resolve(topLevel) === resolve(ROOT)
|
|
162
|
+
ok(sameRepo,
|
|
163
|
+
"当前目录就是仓库根(git rev-parse --show-toplevel === cwd)",
|
|
164
|
+
sameRepo ? "" : `cwd=${ROOT},仓库根=${topLevel || "(取不到)"} —— 发布脚本会写错对象`,
|
|
165
|
+
"cd 到 git rev-parse --show-toplevel 打印的那个目录再跑")
|
|
166
|
+
|
|
144
167
|
// ── 输出 ──────────────────────────────────────────────────────────────
|
|
145
168
|
|
|
146
169
|
report()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-lib.mjs —— `release.mjs` 的纯逻辑层(可单测,零副作用)
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独拆出来:2026-10-31 的真实事故 —— 仓库 `remote.origin.url` 被写成
|
|
5
|
+
* `git+https://github.com/…`(非法 scheme),`release.mjs` 的体检**照样通过**(正则能匹配
|
|
6
|
+
* `github.com:`),直到真做 `git push` 才炸;而当时的执行顺序是「push → tag → **Release → npm**」,
|
|
7
|
+
* 于是留下「npm 已发、Release 已建、但 main 没推、tag 指向错误 commit」的半途状态。
|
|
8
|
+
*
|
|
9
|
+
* 本文件把两件事变成可测的纯函数:
|
|
10
|
+
* ① `classifyRemote()`:**先判 scheme 再判仓库**,`git+*` 直接拒,并给出可执行的修法;
|
|
11
|
+
* ② `runTransaction()`:**顺序执行 + 首个失败即中止**,让「git 步骤成功之后才允许副作用」成为结构保证,
|
|
12
|
+
* 而不是散落在调用点的 if。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 非法 remote scheme 前缀:`git+https:` / `git+ssh:` / `git+file:` … */
|
|
16
|
+
const BAD_SCHEME_RE = /^git\+/i
|
|
17
|
+
|
|
18
|
+
/** GitHub 仓库的三种合法写法:scp 式 ssh、`ssh://`、`https://`。 */
|
|
19
|
+
const SSH_RE = /^[a-z0-9._-]+@github\.com:([^/\s]+)\/([^/\s]+)$/i
|
|
20
|
+
const SSH_URL_RE = /^ssh:\/\/(?:[a-z0-9._-]+@)?github\.com(?::\d+)?\/([^/\s]+)\/([^/\s]+)\/?$/i
|
|
21
|
+
const HTTPS_RE = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/?$/i
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解析 GitHub remote。
|
|
25
|
+
* @returns {{owner:string, repo:string}|null} 不是 GitHub 或用了非法 scheme 时返回 null
|
|
26
|
+
*/
|
|
27
|
+
export function parseRepo(url) {
|
|
28
|
+
const raw = String(url ?? "").trim()
|
|
29
|
+
if (!raw || BAD_SCHEME_RE.test(raw)) return null
|
|
30
|
+
for (const re of [SSH_RE, SSH_URL_RE, HTTPS_RE]) {
|
|
31
|
+
const m = raw.match(re)
|
|
32
|
+
if (m) {
|
|
33
|
+
const repo = m[2].replace(/\.git$/i, "")
|
|
34
|
+
if (!m[1] || !repo) return null
|
|
35
|
+
return { owner: m[1], repo }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 判断 remote 能不能用,并给出人话理由。
|
|
43
|
+
* @returns {{ok:true, repo:{owner:string,repo:string}, url:string} | {ok:false, reason:string}}
|
|
44
|
+
*/
|
|
45
|
+
export function classifyRemote(url) {
|
|
46
|
+
const raw = String(url ?? "").trim()
|
|
47
|
+
if (!raw) {
|
|
48
|
+
return { ok: false, reason: "remote.origin.url 为空 —— 没有远端就发不了版(git remote add origin …)" }
|
|
49
|
+
}
|
|
50
|
+
if (BAD_SCHEME_RE.test(raw)) {
|
|
51
|
+
const scheme = raw.split(":").slice(0, 2).join(":").split("+")[0]
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason:
|
|
55
|
+
`remote.origin.url 用了非法协议(${raw.slice(0, raw.indexOf(":") + 1)}…)—— ` +
|
|
56
|
+
`git 会去找 remote helper「git-remote-${scheme}+」并报 "is not a git command"。` +
|
|
57
|
+
`改成 git@github.com:<owner>/<repo>.git(推荐)或 https://github.com/<owner>/<repo>.git:` +
|
|
58
|
+
`git remote set-url origin git@github.com:<owner>/<repo>.git`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const repo = parseRepo(raw)
|
|
62
|
+
if (!repo) {
|
|
63
|
+
return { ok: false, reason: `remote.origin.url 不是 GitHub 仓库(${raw})—— 这个脚本只处理 GitHub` }
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, repo, url: raw }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 顺序执行一串步骤,**第一个失败就停**,后面的步骤一律不执行。
|
|
70
|
+
*
|
|
71
|
+
* 这就是「git push 成功之后才允许 npm / Release」的结构保证:把有副作用的动作排进 steps,
|
|
72
|
+
* 前面任何一步抛错,`ok` 就是 false,调用方据此直接退出(不再执行剩余副作用)。
|
|
73
|
+
*
|
|
74
|
+
* @param {{name:string, run:() => any}[]} steps
|
|
75
|
+
* @param {{
|
|
76
|
+
* onStart?:(step:{name:string}, index:number)=>void,
|
|
77
|
+
* onSettled?:(step:{name:string}, error:any, value:any)=>void,
|
|
78
|
+
* }} [opts]
|
|
79
|
+
* @returns {{ok:boolean, done:string[], failed:string|null, error:any, aborted:string[]}}
|
|
80
|
+
*/
|
|
81
|
+
export function runTransaction(steps, opts = {}) {
|
|
82
|
+
const list = Array.isArray(steps) ? steps : []
|
|
83
|
+
const done = []
|
|
84
|
+
for (let i = 0; i < list.length; i++) {
|
|
85
|
+
const step = list[i]
|
|
86
|
+
if (opts.onStart) opts.onStart(step, i)
|
|
87
|
+
try {
|
|
88
|
+
const value = step.run()
|
|
89
|
+
if (opts.onSettled) opts.onSettled(step, null, value)
|
|
90
|
+
done.push(step.name)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (opts.onSettled) opts.onSettled(step, error, undefined)
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
done,
|
|
96
|
+
failed: step.name,
|
|
97
|
+
error,
|
|
98
|
+
aborted: list.slice(i + 1).map((s) => s.name),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, done, failed: null, error: null, aborted: [] }
|
|
103
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
// 体检项(任一不过就退出码 1,且 --yes 也不动手):
|
|
14
14
|
// ① package.json 有 version ② CHANGELOG 有这一版那一段
|
|
15
15
|
// ③ 工作区干净 ④ 当前在默认分支
|
|
16
|
-
// ⑤
|
|
17
|
-
// ⑦
|
|
16
|
+
// ⑤ remote 是合法 GitHub URL(拒 git+https 之类) ⑥ 远端可达(ls-remote 必须成功)
|
|
17
|
+
// ⑦ 不落后远端(fetch 必须成功,失败=红,不再吞成 note) ⑧ tag 还没被占
|
|
18
|
+
//
|
|
19
|
+
// 执行顺序(**事务化**:前一步失败 ⇒ 后面一步根本不执行):
|
|
20
|
+
// push 分支 → tag -a + push tag → npm publish(--npm 时)→ GitHub Release
|
|
21
|
+
// ⇒ git 步骤没过,绝不会出现「npm 已发 / Release 已建,但 main 没推」的半途状态。
|
|
18
22
|
//
|
|
19
23
|
// 需要 GitHub token 才能建 Release:
|
|
20
24
|
// GITHUB_TOKEN / GH_TOKEN 环境变量。没有也能跑 —— 它会打印手工建 Release 的命令。
|
|
@@ -22,6 +26,7 @@
|
|
|
22
26
|
import { execSync, execFileSync } from "node:child_process"
|
|
23
27
|
import { readFileSync, existsSync } from "node:fs"
|
|
24
28
|
import { join } from "node:path"
|
|
29
|
+
import { classifyRemote, runTransaction } from "./release-lib.mjs"
|
|
25
30
|
|
|
26
31
|
const ROOT = process.cwd()
|
|
27
32
|
const argv = new Set(process.argv.slice(2))
|
|
@@ -136,40 +141,70 @@ if (branch) {
|
|
|
136
141
|
if (!dirty) bad("工作区有未提交的改动 —— 发布的内容必须和提交一致")
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
144
|
+
/**
|
|
145
|
+
* remote 能不能用 —— 先判 scheme 再判仓库。
|
|
146
|
+
* 这一层是 2026-10-31 事故的直接产物:`git+https://…` 曾被当成合法 GitHub URL,
|
|
147
|
+
* 体检全绿、push 全崩。判定逻辑在 release-lib.mjs 里(可单测)。
|
|
148
|
+
*/
|
|
149
|
+
const remoteCheck = remote ? classifyRemote(remote) : { ok: false, reason: "没有 remote.origin" }
|
|
150
|
+
const gh = remoteCheck.ok ? remoteCheck.repo : null
|
|
151
|
+
if (!remoteCheck.ok) bad(remoteCheck.reason)
|
|
146
152
|
|
|
147
153
|
let behind = 0
|
|
148
154
|
if (gh && branch) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
// ⑤/⑥ 远端可达:ls-remote 必须成功。失败 ⇒ 红(不许「跳过检查」继续发版)。
|
|
156
|
+
if (!shOk("git ls-remote --heads origin")) {
|
|
157
|
+
bad(
|
|
158
|
+
"git ls-remote origin 失败 —— 远端不可达(URL 写错 / 凭据失效 / 网络不通)。" +
|
|
159
|
+
"先跑 `git ls-remote origin` 亲自确认能通,再发版。",
|
|
160
|
+
)
|
|
161
|
+
} else {
|
|
162
|
+
try {
|
|
163
|
+
sh("git fetch origin --quiet")
|
|
164
|
+
const counts = sh(`git rev-list --left-right --count origin/${branch}...HEAD`)
|
|
165
|
+
const [b, a] = counts.split(/\s+/).map(Number)
|
|
166
|
+
behind = b || 0
|
|
167
|
+
if (behind > 0) bad(`落后远端 ${behind} 个提交 —— 先 pull / merge,别在旧基线上发版`)
|
|
168
|
+
if (a > 0) note(`本地领先远端 ${a} 个提交(发布时会一起推上去)`)
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// ⑦ fetch 失败不再是 note —— 那就是「不知道远端正不正常」,不能发版。
|
|
171
|
+
bad(`git fetch origin 失败(${String(e.stderr || e.message).trim().split("\n")[0]})—— 远端状态未知,先修好再发版`)
|
|
172
|
+
}
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
|
|
161
|
-
// ──
|
|
176
|
+
// ── ⑧ tag 是否已被占(并区分「指向本 commit」与「指向别的 commit」)──────
|
|
177
|
+
// 1.11.3 的现场:Release API 在 push 失败后**自动建了一个轻量 tag**,指向当时远端的旧 commit。
|
|
178
|
+
// 那种情况不能默默 force —— 必须让人确认「这个 tag 是不是发错了」。
|
|
162
179
|
|
|
163
180
|
let tagExistsLocal = false
|
|
164
181
|
let tagExistsRemote = false
|
|
182
|
+
let tagCommitLocal = ""
|
|
183
|
+
let tagCommitRemote = ""
|
|
165
184
|
if (gh) {
|
|
166
185
|
tagExistsLocal = shOk(`git rev-parse -q --verify refs/tags/${tag}`)
|
|
186
|
+
if (tagExistsLocal) tagCommitLocal = sh(`git rev-parse -q --verify refs/tags/${tag}^{commit}`)
|
|
167
187
|
try {
|
|
168
188
|
const out = sh(`git ls-remote --tags origin refs/tags/${tag}`)
|
|
169
189
|
tagExistsRemote = out.length > 0
|
|
190
|
+
// ls-remote 对 annotated tag 会同时给 `<tagobj>\trefs/tags/x` 和 `<commit>\trefs/tags/x^{}`;
|
|
191
|
+
// 取 deref 行(`^{}`)的 sha 才是它指向的 commit。
|
|
192
|
+
const deref = out.split("\n").find((l) => l.includes(`refs/tags/${tag}^{}`))
|
|
193
|
+
const plain = out.split("\n").find((l) => l.endsWith(`refs/tags/${tag}`))
|
|
194
|
+
tagCommitRemote = (deref ?? plain ?? "").split(/\s+/)[0] ?? ""
|
|
170
195
|
} catch {}
|
|
196
|
+
const headSha = shOk("git rev-parse HEAD") ? sh("git rev-parse HEAD") : ""
|
|
197
|
+
const pointsAtHead = (sha) => !!sha && !!headSha && (sha === headSha || headSha.startsWith(sha))
|
|
171
198
|
if (tagExistsLocal || tagExistsRemote) {
|
|
172
|
-
|
|
199
|
+
if (tagExistsRemote && tagCommitRemote && !pointsAtHead(tagCommitRemote)) {
|
|
200
|
+
bad(
|
|
201
|
+
`远端 tag ${tag} 已存在,但指向 ${tagCommitRemote.slice(0, 12)}(不是 HEAD ${headSha.slice(0, 12)})—— ` +
|
|
202
|
+
`可能是上次发布中断时由 Release API 自动建的轻量 tag。脚本**不会自动 force**。` +
|
|
203
|
+
`确认这个 tag 发错之后,手工修:git push --force origin refs/tags/${tag}:refs/tags/${tag}`,
|
|
204
|
+
)
|
|
205
|
+
} else {
|
|
206
|
+
bad(`tag ${tag} 已存在 —— 同一个版本号不能发两次;先 bump version`)
|
|
207
|
+
}
|
|
173
208
|
}
|
|
174
209
|
}
|
|
175
210
|
|
|
@@ -205,16 +240,16 @@ for (const n of notes) console.log(C.yellow(` ! ${n}`))
|
|
|
205
240
|
// ── 计划 ─────────────────────────────────────────────────────────────
|
|
206
241
|
|
|
207
242
|
const plan = []
|
|
208
|
-
plan.push({ what: `git push origin ${branch}`, why: "第 4 步:git 源检测看默认分支 HEAD" })
|
|
243
|
+
plan.push({ what: `git push origin ${branch}`, why: "第 4 步:git 源检测看默认分支 HEAD(**失败即中止,后面一步都不做**)" })
|
|
209
244
|
plan.push({ what: `git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, why: "第 6 步:annotated tag(带消息)让「某 bug 出在哪版」可查,且 GitHub Release 才有说明" })
|
|
245
|
+
if (ALSO_NPM) {
|
|
246
|
+
const script = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
|
|
247
|
+
plan.push({ what: script, why: "第 5 步:npm 源的用户比 registry 上的 version(**排在 Release 之前**:npm 失败就不建 Release)" })
|
|
248
|
+
}
|
|
210
249
|
plan.push({
|
|
211
250
|
what: `POST /repos/${gh.owner}/${gh.repo}/releases`,
|
|
212
251
|
why: `第 6 步:Release 正文 = CHANGELOG 的 ${version} 段(${releaseBody.split("\n").length} 行,已去掉 > 起因)`,
|
|
213
252
|
})
|
|
214
|
-
if (ALSO_NPM) {
|
|
215
|
-
const script = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
|
|
216
|
-
plan.push({ what: script, why: "第 5 步:npm 源的用户比 registry 上的 version" })
|
|
217
|
-
}
|
|
218
253
|
plan.push({ what: "四条验证", why: "第 7 步:npm 版本 / tarball 内容 / magpie plugin list / Release 在" })
|
|
219
254
|
|
|
220
255
|
console.log(C.bold("计划:\n"))
|
|
@@ -228,27 +263,84 @@ if (DRY) {
|
|
|
228
263
|
process.exit(0)
|
|
229
264
|
}
|
|
230
265
|
|
|
231
|
-
// ──
|
|
266
|
+
// ── 真做(事务化:前一步失败 ⇒ 后面一步根本不执行)────────────────────
|
|
232
267
|
|
|
233
268
|
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ""
|
|
234
269
|
console.log()
|
|
235
270
|
|
|
236
|
-
|
|
237
|
-
|
|
271
|
+
const doneSteps = []
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 失败即中止:把「后面还有什么没做」写清楚,然后退出 1。
|
|
275
|
+
* 这是 2026-10-31 事故的教训 —— 当时 push/tag 全失败,脚本却继续建 Release、继续 npm publish,
|
|
276
|
+
* 留下「npm 有、main 没推、tag 指向旧 commit」的半途状态。
|
|
277
|
+
*/
|
|
278
|
+
function abort(step, detail) {
|
|
279
|
+
console.log(C.red(`\n✗ 已中止:${step} 失败 —— 后续步骤全部未执行`))
|
|
280
|
+
console.log(C.dim(` 已完成:${doneSteps.length ? doneSteps.join(" → ") : "(无)"}`))
|
|
281
|
+
if (detail) console.log(C.dim(` ${detail}`))
|
|
282
|
+
console.log(
|
|
283
|
+
C.dim(
|
|
284
|
+
` 未执行:${["tag -a " + tag, ALSO_NPM ? "npm publish" : null, "GitHub Release"]
|
|
285
|
+
.filter(Boolean)
|
|
286
|
+
.join(" · ")}`,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
console.log(C.dim(" ⇒ 没有任何新的对外副作用被制造出来。修好远端后重跑同一条命令。\n"))
|
|
290
|
+
process.exit(1)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ①② 推分支 + annotated tag —— 走 runTransaction(首步失败 ⇒ tag 步根本不执行)
|
|
294
|
+
const gitTx = runTransaction(
|
|
295
|
+
[
|
|
296
|
+
{
|
|
297
|
+
name: `push ${branch}`,
|
|
298
|
+
label: `push ${branch}`,
|
|
299
|
+
run: () => sh(`git push origin ${branch}`, { stdio: "pipe" }),
|
|
300
|
+
hint: "远端不可达或被拒。**tag / npm / Release 一件都没做。**",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: `tag ${tag}`,
|
|
304
|
+
label: `tag ${tag}`,
|
|
305
|
+
run: () => sh(`git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, { stdio: "pipe" }),
|
|
306
|
+
hint: "本地 tag 可能已建;远端 tag 没上去。修好后单独补:git push origin " + tag,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
{
|
|
310
|
+
onStart: (step) => process.stdout.write(` ${step.label} … `),
|
|
311
|
+
onSettled: (step, err) => {
|
|
312
|
+
if (!err) return console.log(C.green("ok"))
|
|
313
|
+
console.log(C.red("失败"))
|
|
314
|
+
console.log(C.dim(String(err.stderr || err.message).trim().split("\n").slice(0, 4).join("\n")))
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
if (!gitTx.ok) {
|
|
319
|
+
const failedStep = gitTx.done.length === 0 ? `git push origin ${branch}` : `tag -a ${tag} && git push origin ${tag}`
|
|
320
|
+
const hint = gitTx.done.length === 0
|
|
321
|
+
? "远端不可达或被拒。**tag / npm / Release 一件都没做。**"
|
|
322
|
+
: "本地 tag 可能已建;远端 tag 没上去。修好后单独补:git push origin " + tag
|
|
323
|
+
abort(failedStep, hint)
|
|
324
|
+
}
|
|
325
|
+
doneSteps.push(`push ${branch}`, `tag ${tag}`)
|
|
326
|
+
|
|
327
|
+
// ③ npm 发布(在 Release 之前:npm 失败就不该留下一个「Release 说有、npm 上没有」的状态)
|
|
328
|
+
let published = null
|
|
329
|
+
if (ALSO_NPM) {
|
|
330
|
+
const npmScript = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
|
|
331
|
+
console.log(C.dim(`\n ${npmScript}(npm 登记有 1~2 分钟延迟,别在这窗口里重试)\n`))
|
|
238
332
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
} catch
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
333
|
+
execSync(npmScript, { cwd: ROOT, stdio: "inherit" })
|
|
334
|
+
published = true
|
|
335
|
+
doneSteps.push(`npm publish ${version}`)
|
|
336
|
+
} catch {
|
|
337
|
+
published = false
|
|
338
|
+
}
|
|
339
|
+
if (!published) {
|
|
340
|
+
abort(`${npmScript}`, "npm 没发出去(OTP / 2FA / 权限 / 网络)。**GitHub Release 未创建。**")
|
|
246
341
|
}
|
|
247
342
|
}
|
|
248
343
|
|
|
249
|
-
const pushed = run(`git push origin ${branch}`, `push ${branch}`)
|
|
250
|
-
const tagged = run(`git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, `tag ${tag}`)
|
|
251
|
-
|
|
252
344
|
let released = false
|
|
253
345
|
|
|
254
346
|
/** 这个仓库有没有 release.yml(有的话推 tag 就会自动建 Release,我们不该再建一遍) */
|
|
@@ -354,19 +446,6 @@ if (token) {
|
|
|
354
446
|
console.log(C.dim(" 正文直接把 CHANGELOG 里 " + version + " 那一段粘过去"))
|
|
355
447
|
}
|
|
356
448
|
|
|
357
|
-
let published = null
|
|
358
|
-
if (ALSO_NPM) {
|
|
359
|
-
const script = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
|
|
360
|
-
console.log(C.dim(`\n ${script}(npm 登记有 1~2 分钟延迟,别在这窗口里重试)\n`))
|
|
361
|
-
try {
|
|
362
|
-
execSync(script, { cwd: ROOT, stdio: "inherit" })
|
|
363
|
-
published = true
|
|
364
|
-
} catch {
|
|
365
|
-
published = false
|
|
366
|
-
console.log(C.red(" npm 发布失败"))
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
449
|
// ── 第 7 步留给人看的清单 ─────────────────────────────────────────────
|
|
371
450
|
|
|
372
451
|
console.log(C.bold("\n第 7 步 · 四条验证(逐条跑,别只看版本号):\n"))
|
|
@@ -385,5 +464,11 @@ console.log(
|
|
|
385
464
|
),
|
|
386
465
|
)
|
|
387
466
|
|
|
388
|
-
|
|
467
|
+
// 走到这里说明 push / tag /(npm)全部成功。Release 失败仍是失败(否则「npm 有、Release 没有」)。
|
|
468
|
+
const failed = !!token && !released
|
|
469
|
+
if (failed) {
|
|
470
|
+
console.log(C.red("\n✗ GitHub Release 没建成 —— push/tag/npm 都已完成,只差 Release:"))
|
|
471
|
+
console.log(C.dim(" 手工建:GitHub → Releases → Draft a new release → 选 " + tag))
|
|
472
|
+
console.log(C.dim(" 或重跑本脚本(tag 已存在会先被 ⑧ 拦住;那就手工建一次)"))
|
|
473
|
+
}
|
|
389
474
|
process.exit(failed ? 1 : 0)
|