feat(88): 版本線判別器從「有沒有 v」換成 DAEMON_LINE,產生端才吐得出裸號

leo 2026-08-17:「對外號就是三個數字,不要 v」。

08-18 第一次改失敗的理由要先講清楚:那個 `v` **兼任版本線判別器**
(daemon `v0.18.x` vs 雲端引擎 `1.4.x`),有四處在讀它。天真拿掉之後那些閘
**不報錯**,而是往下比對到更舊的 `## v0.18.29(…)` ⇒ 打包出 0.18.30 的執行檔、
manifest 卻宣稱是 v0.18.29。**靜默,21 站全綠。**

── 四處讀取點,各自靠什麼分辨(換掉之後)──────────────────────────
① `daemon-notes.DAEMON_RELEASED_RE`(daemon-in-bundle-gate 與 ship.mjs daemon-sync
   共用的那一份,第一輪已從四份拷貝收攏成一份)
   舊:`^## (v\d+\.\d+\.\d+)(` ——「有 v = daemon」
   新:`daemonReleasedReFor(repoRoot)` 由 `collector/DAEMON_LINE` 產生
       `^## (v?0\.18\.\d+)(` ——「在宣告的那條線上 = daemon」
② `daemon-in-bundle-gate.collectFacts/judge`(同上那份的使用端)
   新增一項 `daemon-line-declared` 排在**所有檢查最前面**:
   讀不出 DAEMON_LINE = 沒有判別器 ⇒ 當場斷,不給預設值。
   另記一個**只給錯誤訊息用**的 `changelogTopAny`,讓「沒中」時說得出
   「你指到的是 1.4.47,那是雲端那條線」——判斷與診斷分開。
③ `ship.mjs` daemon-sync 站:同 ①,並在丟例外時附上量到的事實。
④ `daemon-notes.changelogRelFor()`
   舊:`/^v/.test(version)` ⇒ 裸號時代會把 daemon 的 0.18.31 指去雲端的
   CHANGELOG.md(叫人去錯的檔補說明)。新:比對版本線。

⇒ 判別依據換成**宣告出來的事實**,不是外觀 ⇒ 產生端這才拿得掉那個 `v`:
   `daemon-version.py` 升版路徑 `"v%d.%d.%d"` → `"%d.%d.%d"`。
   「重打同一版」路徑刻意**不動**(沿用 prev_prefix):selfupdate.go 的
   newerThanCurrent() 用字串不等於判斷更新,重打時換寫法會讓每一台已安裝的機器
   看到一個內容一模一樣、卻宣稱是新版的假更新。

── 測試(+7 支,每支都附反向對照)──────────────────────────────────
故意製造「版本線判別會出錯」的情境,並證明**新的會擋、舊的會靜默放行**:
  · 產生端吐裸號 → 閘讀到最新那一版(反向對照:舊判別式讀到更舊的一版)
  · 源碼樹 0.18.31/bundle 停在 v0.18.30 → 擋(舊判別式在此判「相符」⇒ 放行)
  · DAEMON_LINE 換 0.19 而 changelog 還是 0.18.x → 擋(舊做法看不出線換了)
  · DAEMON_LINE 缺席/寫壞 → 擋,且擋在第一項
只有「新的怎麼跑」而沒有「舊的怎麼跑」的話,這些測試就只是回音,不是閘
(wiki/mistakes.md「測試複製了實作邏輯就不再是閘」)。

installer 262/262(+5)、collector daemon-version 11/11(+2)。

inkstone/arcrun-rag#88
This commit is contained in:
2026-08-18 16:33:27 +08:00
parent 681f9a10c8
commit 9299ec8960
2 changed files with 125 additions and 67 deletions
+21 -18
View File
@@ -357,28 +357,31 @@ def main():
nxt = (major, minor, 0)
else:
nxt = (prev[0], prev[1], prev[2] + 1)
# 🔴 這裡**還是吐 `v`**,而 leo 2026-08-17 要的是「不要 v」。這不是漏掉,是**擋住了**:
# 🔴 **裸號就是從這一行開始的**(leo 2026-08-17「對外號就是三個數字,不要 v」)。
#
# 這一行往下走 `heading` 寫進 changelog、`print(version)` 餵給 build-*.sh 的
# `${VERSION}` ⇒ 檔名(`Arcrun-v0.18.x.dmg`)與 `manifest.daemon.version`
# `${VERSION}` ⇒ 檔名(`Arcrun-0.18.x.dmg`)與 `manifest.daemon.version`
# ⇒ `install.arcrun.dev/api/latest` 的 `daemon.version`。
# **leo 看到並質問的那個 `v`,就是從這一行長出去的**
# **leo 看到並質問的那個 `v`,就是從這一行長出去的**,所以也只能從這一行拿掉。
#
# 2026-08-18 實際試著改成裸號,當場撞到:**那個 `v` 目前身兼「哪一條版本線」的
# 判別器**。三支閘(daemon-freshnessdaemon-in-bundle-gateship.mjs 的
# daemon-sync)+`daemon-notes.changelogRelFor()` 都靠「有沒有 v」認出
# 「這份 changelog 到底是不是 daemon 的」。一改裸號,daemon 的閘就開始把雲端那條
# `## 1.4.47(…)` 當成 daemon 版本撈走 ⇒ `daemon-in-bundle-gate.test.mjs`
# 的 ①⑮⑯ 三支轉紅,而它們釘的正是 D95 第二輪剛修好的病
# (出貨線指到錯的 changelog 卻**安靜放行**)。
# ── 為什麼 2026-08-18 第一次改失敗,第二次才成立 ─────────────────────
# 第一次天真地把 `v` 拿掉,當場撞到:**那個 `v` 身兼「哪一條版本線」的判別器**。
# 出貨線那幾道閘(daemon-in-bundle-gateship.mjs 的 daemon-sync
# `daemon-notes.changelogRelFor()`)全靠「有沒有 v」認出「這份 changelog 是不是
# daemon 的」。一改裸號,它們**不報錯**,而是往下比對到更舊的 `## v0.18.29(…)`
# ⇒ 打包出 0.18.30 的執行檔、manifest 卻宣稱是 v0.18.29。靜默,21 站全綠。
#
# ⇒ 要落到產生端,得**先給那三支閘一個替代判別器**(建議拿 `DAEMON_LINE`
# 宣告的線去比對,例 `^## v?0\.18\.\d+`——同時擋掉「錯的檔」與「錯的線」,
# 且不依賴外觀)。那會動到 `topReleasedVersion()` 等對外簽章、要跨 installer/
# 與 collector/ 一起改,與正在進行的 collector 拆 repo 相衝
# ⇒ 交接說明見 `inkstone/arcrun-rag#88`。**在那之前不要單獨把這一行改裸**,
# 單獨改的下場是「打包出新版執行檔,manifest 卻宣稱是舊版」——靜默、且 21 站全綠。
version = "v%d.%d.%d" % nxt
# 第二輪(本次)先換掉判別的承載:改用 `DAEMON_LINE` 宣告的那條線比對
# `^## v?0\.18\.\d+`,見 `collector/cmd/arcrun-app/daemon-notes.mjs`
# 的 `daemonReleasedRe`)。它同時擋掉「錯的檔」與「錯的線」,且**不依賴外觀**
# ⇒ 裸號與既有的帶 v 舊版都認得。判別器換好之後,這一行才拿得掉
#
# ⚠️ 「重打同一版」那條路徑**不走這裡**(見上面 `has_unreleased` 為假的分支),
# 它沿用 `prev_prefix` ⇒ 重打 `v0.18.30` 還是叫 `v0.18.30`。
# 那不是漏改,是必要的:`selfupdate.go` 的 `newerThanCurrent()` 用字串不等於
# 判斷有沒有新版,重打時換寫法會讓每一台已安裝的機器看到一個**內容一模一樣、
# 卻宣稱是新版**的假更新。⇒ 只有**真的升版**才吐裸號,而那正是這一行。
version = "%d.%d.%d" % nxt
if stamp:
# 🔴 2026-08-18inkstone/arcrun-rag#88):**順序反過來了,這是第二個自鎖。**
@@ -387,7 +390,7 @@ def main():
# 那棵樹底下(D95 第一輪搬進 collector/),所以記下來的是**還沒戳版**那一刻的
# 指紋——它描述的那棵樹,從寫完宣告的那一刻起就不存在了。
#
# 實撞(A/B 重現,見 daemon-version.test.mjs ):build-mac.sh 戳完版打好 dmg
# 實撞(A/B 重現,見 daemon-version.test.mjs ):build-mac.sh 戳完版打好 dmg
# 同一輪接著跑 build-win.sh ⇒ 沒有草稿段 ⇒ 走「重打同一版」路徑 ⇒ 現在的指紋
# (含已戳版的 changelog)≠ 帳本裡那個(戳版前的)⇒ 而 dist/ 已經有 dmg
# ⇒ 判「版號已對應另一份原始碼」⇒ **第二個平台永遠打不出來**。
+104 -49
View File
@@ -1,21 +1,24 @@
/**
* daemon-version.test.mjs — 證明**產生端**吐的是裸號(inkstone/arcrun-rag#88
*
* 🔴 **這支測試現在釘的是「還沒做到」,不是「做到了」**(誠實優先於好看)。
*
* leo 2026-08-17 定「對外號就是三個數字,不要 v」;隔天他問「剛剛不是說不要 v?」,
* leo 2026-08-17:「對外號就是三個數字,不要 v」。隔天他問「剛剛不是說不要 v?」,
* 因為 `install.arcrun.dev/api/latest` 照舊回 `daemon.version = "v0.18.28"`。
* 那個 `v` 的產地就是本目錄的 `daemon-version.py`。
*
* 2026-08-18 實際改成裸號,當場撞到:**那個 `v` 目前身兼「哪一條版本線」的判別器**
* (三支閘+`changelogRelFor()` 靠它認出「這份 changelog 是不是 daemon 的」)。
* 一改裸號,daemon 的閘就把雲端那條 `## 1.4.47(…)` 當成 daemon 版本撈走
* ⇒ `installer/scripts/daemon-in-bundle-gate.test.mjs` 的 ①⑮⑯ 轉紅,
* 而它們釘的正是 D95 第二輪剛修好的「指到錯的 changelog 卻安靜放行」
* ── 這支測試的來歷:它曾經釘的是「還沒做到」──────────────────────────────
* 2026-08-18 第一次改裸號當場翻車:**那個 `v` 身兼「哪一條版本線」的判別器**,
* 出貨線那幾道閘靠它認出「這份 changelog 是不是 daemon 的」。一改裸號它們
* **不報錯**,而是往下比對到更舊的 `## v0.18.29(…)`
* ⇒ 打包出 0.18.30 的執行檔、manifest 卻宣稱是 v0.18.29。靜默,21 站全綠
* 於是當時把 ①②③ 的期望值釘成「仍吐 v」,並把「為什麼還不能改」寫成兩支會說話的測試。
*
* ⇒ 所以本檔的測試**斷言的是現況(一律吐 `v`)**,並把「為什麼還不能改」釘成
* 一支會說話的測試。等替代判別器就位(見 `daemon-version.py` 那段註解與
* `inkstone/arcrun-rag#88`),把 ①②③ 的期望值改成裸號,這支就變成驗收。
* 第二輪(本次)先換掉判別的承載——改用 `collector/DAEMON_LINE` 宣告的那條線
* `daemon-notes.mjs` 的 `daemonReleasedRe`)——**判別器換好之後,`v` 才拿得掉**。
* ⇒ ①②③ 現在釘的是裸號(驗收);⑤⑥⑦⑧ 從「為什麼還不能改」改寫成
* **「拿掉 v 之後,判別還成不成立」的閘**,每一支都附一組**反向對照**:
* 同一份輸入餵給舊的嚴格判斷式會靜默讀到**更舊的一版**——
* 那幾行證明這支測試真的在鑑別,不是把實作抄一遍的回音
* `system-dev/wiki/mistakes.md`:「測試複製了實作邏輯就不再是閘,是回音」)。
*
* 為什麼住在 `collector/` 而不是 `installer/scripts/`
* 被測的 `daemon-version.py` 是 collector 自己的版本產生器(D95 第一輪把它的三個輸入
@@ -60,46 +63,98 @@ function runProducer({ changelog, line = '0.18', stamp = false }) {
const UNRELEASED = '## 下一版(未發佈)\n\n- 修了一件事\n\n';
const OLD_V = '## v0.18.282026-08-16\n\n- 舊的那一版\n';
test('① 升版:**目前**仍吐帶 v 的版本號(leo 要的裸號尚未落到產生端', () => {
test('① 升版:**裸號**leo 2026-08-17「對外號就是三個數字,不要 v」', () => {
const { version } = runProducer({ changelog: UNRELEASED + OLD_V });
assert.equal(version, 'v0.18.29');
// 🔴 這一行是給未來的人看的:改成裸號之前,先讀 daemon-version.py 裡
// `version = "v%d.%d.%d" % nxt` 上方那段——單獨改這裡會讓 manifest 說謊。
assert.equal(version, '0.18.29');
});
test('② 升版並戳章:寫進 changelog 的標題也帶 v(兩邊一致,不會自我矛盾)', () => {
test('② 升版並戳章:寫進 changelog 的標題也是裸號(兩邊一致,不會自我矛盾)', () => {
const { version, changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
assert.equal(version, 'v0.18.29');
assert.match(changelogAfter, /^## v0\.18\.29\d{4}-\d{2}-\d{2}$/m);
assert.equal(version, '0.18.29');
assert.match(changelogAfter, /^## 0\.18\.29\d{4}-\d{2}-\d{2}$/m);
});
test('③ 換版本線:新線第一版(同樣帶 v', () => {
test('③ 換版本線:新線第一版(同樣是裸號', () => {
const { version } = runProducer({ changelog: UNRELEASED + OLD_V, line: '0.19' });
assert.equal(version, 'v0.19.0');
assert.equal(version, '0.19.0');
});
test('④ 重打同一版:冪等,不虛增(原有行為,未動)', () => {
const { version } = runProducer({ changelog: OLD_V });
assert.equal(version, 'v0.18.28');
test('④ 重打同一版:冪等,不虛增,而且**沿用那一版當初的寫法**', () => {
// 🔴 舊版一輩子維持 `v0.18.28`。這不是漏改:`selfupdate.go` 的 newerThanCurrent()
// 用「字串不等於」判斷有沒有新版 ⇒ 重打時改寫法,每一台已安裝的機器都會看到一個
// 內容一模一樣、卻宣稱是新版的**假更新**。
assert.equal(runProducer({ changelog: OLD_V }).version, 'v0.18.28');
// 而已經是裸號的那一版,重打也維持裸號(同一條規則,不必記例外)。
assert.equal(runProducer({ changelog: '## 0.18.302026-08-18\n\n- 裸號那一版\n' }).version, '0.18.30');
});
test('⑤ 🔴 判別器還在:daemon 的判斷式**不可以**撈到雲端那條線的版號', () => {
// 這支就是「為什麼還不能改裸號」的本體。`v` 目前是兩條版本線唯一的區分方式:
// installer/scripts/daemon-notes.mjs 的 DAEMON_RELEASED_RE 只認 `^## vX.Y.Z`。
// 放寬成 v? 之後,`## 1.4.47(…)` 會被 daemon 的閘當成 daemon 版本。
const CLOUD = '## 1.4.472026-08-15\n\n- 雲端那條線\n';
const strict = /^## (v\d+\.\d+\.\d+)/m;
const loose = /^## (v?\d+\.\d+\.\d+)/m;
assert.equal(CLOUD.match(strict), null, '嚴格版正確地認不出雲端版號');
assert.equal(CLOUD.match(loose)[1], '1.4.47', '放寬版會撈到雲端版號 ⇒ 這就是那三支閘轉紅的原因');
// ═══════════════════════════════════════════════════════════════════════════
// 🔴 判別器:拿掉 `v` 之後,「這段 changelog 是誰的」靠什麼分辨
//
// 這一組就是本輪的核心。第一次改裸號會翻車,正是因為判別的承載是「有沒有 v」;
// 現在承載換成 `DAEMON_LINE` 宣告的那條線。下面每一支都附**反向對照**:
// 同一份輸入餵給舊做法會發生什麼——沒有那一半,這些測試就只是把實作抄一遍。
// ═══════════════════════════════════════════════════════════════════════════
const CLOUD = '## 1.4.472026-08-15\n\n- 雲端那條線\n';
/** 舊的判別式(釘死 v)。只在測試裡出現,用來證明「新的真的比較強」。 */
const OLD_STRICT = /^## (v\d+\.\d+\.\d+)/m;
test('⑤ 🔴 故意製造判別錯誤:產生端吐裸號時,舊判別式會**靜默讀到更舊的一版**', async () => {
const { daemonReleasedRe } = await import('./daemon-notes.mjs');
// 真的跑一次產生端,拿它戳出來的 changelog(不是手捏一份「像那樣」的字串)。
const { version, changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
assert.equal(version, '0.18.29', '前提:產生端吐裸號');
// ── 反向對照:舊做法在這份輸入上做了什麼 ──
const stale = changelogAfter.match(OLD_STRICT);
assert.equal(stale[1], 'v0.18.28',
'舊判別式不報錯,而是往下比對到**更舊的一版** ⇒ 打包出 0.18.29、manifest 卻宣稱 v0.18.28');
assert.notEqual(stale[1], version, '⇒ 這就是「版本號說謊」,而且靜默:全站會亮綠');
// ── 新做法:讀回來的就是剛剛戳出去的那一版 ──
assert.equal(changelogAfter.match(daemonReleasedRe('0.18'))[1], version);
});
test('⑥ 產生端與那條判斷式對得上(兩份實作靠測試對齊,不靠註解', () => {
const { version } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
const { changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
const strict = /^## (v\d+\.\d+\.\d+)/m;
assert.equal(changelogAfter.match(strict)[1], version,
'產生端戳出來的標題,必須被 installer 那三支閘的判斷式讀得回同一個版本');
test('⑥ 🔴 新判別器認得兩種寫法,但**認不出別條線**(一鬆一緊,兩邊都要', async () => {
const { daemonReleasedRe } = await import('./daemon-notes.mjs');
const re = () => daemonReleasedRe('0.18');
// 鬆:過渡期兩種寫法並存(leo:既有 tag/檔名不回頭改)⇒ 都算數
assert.equal('## 0.18.312026-08-19'.match(re())[1], '0.18.31');
assert.equal('## v0.18.302026-08-18'.match(re())[1], 'v0.18.30');
// 緊:雲端那條線進不來——**這是第一輪把判別式放寬成 `v?` 時炸掉的那一項**
assert.equal(CLOUD.match(re()), null, '雲端 1.4.47 不在 0.18 這條線上');
assert.equal(CLOUD.match(/^## (v?\d+\.\d+\.\d+)/m)[1], '1.4.47',
'反向對照:單純放寬成 v? 會把雲端版號當成 daemon 的撈走');
// 緊:草稿不算(只認已發佈段)
assert.equal('## 下一版(未發佈)'.match(re()), null);
// 緊:換線之後,舊線的版本段**不再算數**——這是舊做法(只看 v)做不到的一格
assert.equal('## v0.18.302026-08-18'.match(daemonReleasedRe('0.19')), null);
assert.equal('## v0.18.302026-08-18'.match(OLD_STRICT)[1], 'v0.18.30',
'反向對照:舊做法照樣把 0.18 的版本當成「daemon 的最新版」,看不出線換了');
});
test('⑦ 沒有版本線就沒有判別器:讀不出 DAEMON_LINE 一律丟例外,不給預設值', async () => {
const { daemonReleasedRe, readDaemonLine } = await import('./daemon-notes.mjs');
for (const bad of [undefined, null, '', '0.18.3', 'v0.18', '亂寫']) {
assert.throws(() => daemonReleasedRe(bad), /需要一條版本線/,
`${JSON.stringify(bad)} 不是一條線,不准被當成線`);
}
// 檔案不存在 ⇒ null(呼叫端要把 null 當成斷,不是當成「用預設值」)
assert.equal(readDaemonLine('/nonexistent/DAEMON_LINE'), null);
});
test('⑧ 產生端與判別器對得上(兩份實作靠測試對齊,不靠註解)', async () => {
const { daemonReleasedRe } = await import('./daemon-notes.mjs');
for (const line of ['0.18', '0.19']) {
const { version, changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, line, stamp: true });
assert.equal(changelogAfter.match(daemonReleasedRe(line))[1], version,
`產生端在 ${line} 這條線戳出來的標題,必須被出貨線那道閘讀回同一個版本`);
}
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -128,7 +183,7 @@ function realishTree({ changelog, line = '0.18' }) {
return { root, appDir, stampIt, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
test(' 🔴 同一輪打三個平台不准被自己的閘擋下(指紋要記「戳版之後」那棵樹)', () => {
test(' 🔴 同一輪打三個平台不准被自己的閘擋下(指紋要記「戳版之後」那棵樹)', () => {
// 病(2026-08-18 A/B 重現):原本是「先記指紋、再把宣告寫進 changelog」,而 changelog
// 就住在指紋涵蓋的樹底下(D95 第一輪搬進 collector/)⇒ 帳本記的是**還沒戳版**那一刻。
// build-mac.sh 打完 dmg,同一輪的 build-win.sh 走「重打同一版」路徑,
@@ -136,27 +191,27 @@ test('⑦ 🔴 同一輪打三個平台不准被自己的閘擋下(指紋要
// 而 ship.mjs 的 daemon-sync 兩個平台都要,缺一不准出貨。
const t = realishTree({ changelog: UNRELEASED + OLD_V });
try {
assert.equal(t.stampIt(), 'v0.18.29', '① mac');
assert.equal(t.stampIt(), '0.18.29', '① mac');
mkdirSync(join(t.appDir, 'dist'), { recursive: true });
writeFileSync(join(t.appDir, 'dist', 'Arcrun-v0.18.29.dmg'), 'x');
assert.equal(t.stampIt(), 'v0.18.29', '② win(同一輪第二支打包線)');
writeFileSync(join(t.appDir, 'dist', 'Arcrun-win-v0.18.29.exe'), 'x');
assert.equal(t.stampIt(), 'v0.18.29', '③ msix(同一輪第三支)');
writeFileSync(join(t.appDir, 'dist', 'Arcrun-0.18.29.dmg'), 'x');
assert.equal(t.stampIt(), '0.18.29', '② win(同一輪第二支打包線)');
writeFileSync(join(t.appDir, 'dist', 'Arcrun-win-0.18.29.exe'), 'x');
assert.equal(t.stampIt(), '0.18.29', '③ msix(同一輪第三支)');
} finally { t.cleanup(); }
});
test(' 🔴 但「戳完版之後真的改了 code」照樣要被擋(別把閘修成永遠放行)', () => {
test(' 🔴 但「戳完版之後真的改了 code」照樣要被擋(別把閘修成永遠放行)', () => {
const t = realishTree({ changelog: UNRELEASED + OLD_V });
try {
assert.equal(t.stampIt(), 'v0.18.29');
assert.equal(t.stampIt(), '0.18.29');
mkdirSync(join(t.appDir, 'dist'), { recursive: true });
writeFileSync(join(t.appDir, 'dist', 'Arcrun-v0.18.29.dmg'), 'x');
writeFileSync(join(t.appDir, 'dist', 'Arcrun-0.18.29.dmg'), 'x');
writeFileSync(join(t.appDir, 'main.go'), 'package main\n// 戳完版才改的\n');
assert.throws(() => t.stampIt(), /已經對應過另一份原始碼/);
} finally { t.cleanup(); }
});
test(' --source-state 是唯讀的,而且答得出「這一版配不配得上現在這棵樹」', () => {
test(' --source-state 是唯讀的,而且答得出「這一版配不配得上現在這棵樹」', () => {
const t = realishTree({ changelog: UNRELEASED + OLD_V });
try {
t.stampIt();
@@ -166,7 +221,7 @@ test('⑨ --source-state 是唯讀的,而且答得出「這一版配不配得
const before = readFileSync(join(t.appDir, '.version-source.json'), 'utf8');
const ok = ask();
assert.equal(ok.version, 'v0.18.29');
assert.equal(ok.version, '0.18.29');
assert.equal(ok.recorded_fingerprint, ok.current_fingerprint, '剛戳完版就該對得上');
assert.deepEqual(ok.changed, []);