diff --git a/cmd/arcrun-app/daemon-version.py b/cmd/arcrun-app/daemon-version.py index 048f493..1c1b745 100755 --- a/cmd/arcrun-app/daemon-version.py +++ b/cmd/arcrun-app/daemon-version.py @@ -71,7 +71,31 @@ SOURCE_LOCK = HERE / ".version-source.json" # 因此全部改變(`collector/cmd/…` → `cmd/…`)⇒ 舊帳本是「用不同單位量出來的數字」, # 比對沒有意義。照既有設計改號讓它自動整本作廢重記,不要手改 JSON。 FINGERPRINT_ALGO = 4 -RELEASED_RE = re.compile(r"^## v(\d+)\.(\d+)\.(\d+)(", re.M) + +# 🔴 2026-08-18(inkstone/arcrun-rag#88):**對外號是三個數字,不帶 `v`** +# (leo 2026-08-17「不要 v」,全文 InkStoneCo `system-dev/wiki/ops-facts.md` §「定案」)。 +# +# 這裡就是那個 `v` 的產地。在此之前本檔一律吐 `v0.18.28`,而那個字串一路長到 +# 使用者眼前的每一處: +# · `build-dmg.sh` 的 `Arcrun-${VERSION}.dmg` → 產物檔名 +# · `manifest.daemon.version` → 桌面小幫手「檢查更新」讀的欄位 +# · `install.arcrun.dev/api/latest` 的 `daemon.version` → 安裝頁讀的欄位 +# ⇒ 規約寫在 wiki 上,而**產生號碼的那條路照舊帶 v**。改任何字串常數都改不掉它, +# 要改的是這支唯一的產生器。 +# +# ── 過渡怎麼走:書寫形式由 changelog 那一行決定,新戳的一律裸號 ────────── +# 紅線是「既有已發出的 tag/檔名不回頭改」。這不是潔癖,是會壞東西: +# `selfupdate.go` 的 `newerThanCurrent()` 判斷更新用的是**字串不等於** +# (`strings.TrimSpace(latest) != cur`,比對 `manifest.daemon.version` 與編進執行檔的字串) +# ⇒ 若「重打同一版」時把 `v0.18.28` 改吐成 `0.18.28`,每一台已安裝的機器 +# 都會看到一個**內容一模一樣、卻宣稱是新版**的更新。那是自己製造的假更新。 +# +# ⇒ 所以規則只有一條:**這一版怎麼寫,看 changelog 上那一行怎麼寫; +# 而新戳出來的那一行一律裸號。** 下一次真的升版(0.18.29)就是裸的; +# 舊的 28 一輩子維持 `v0.18.28`,兩邊都不必記得任何例外。 +# 這條規則**會自己過期**:頂上那段變成裸號之後,重打路徑吐的也是裸號, +# `v?` 只剩「讀得懂歷史」的作用。 +RELEASED_RE = re.compile(r"^## (v?)(\d+)\.(\d+)\.(\d+)(", re.M) def die(msg): @@ -185,8 +209,11 @@ def main(): text = CHANGELOG.read_text() top = RELEASED_RE.search(text) if not top: - die("❌ changelog 裡找不到任何 `## vX.Y.Z(…)` 的版本段落") - prev = (int(top.group(1)), int(top.group(2)), int(top.group(3))) + die("❌ changelog 裡找不到任何 `## X.Y.Z(…)` 的版本段落(舊的 `## vX.Y.Z(…)` 也認)") + # group(1) 是那一版**當初怎麼寫的**('v' 或空字串)——重打同一版時要原樣沿用, + # 否則已安裝的機器會收到一個內容相同卻宣稱是新版的假更新(見 RELEASED_RE 上方)。 + prev_prefix = top.group(1) + prev = (int(top.group(2)), int(top.group(3)), int(top.group(4))) has_unreleased = UNRELEASED in text if has_unreleased and text.index(UNRELEASED) > top.start(): @@ -194,7 +221,9 @@ def main(): if not has_unreleased: # 沒有待發佈內容=重打同一版。冪等,不虛增。 - version = "v%d.%d.%d" % prev + # 🔴 沿用那一版**當初的書寫形式**:重打 `v0.18.28` 還是叫 `v0.18.28`, + # 產物檔名與 manifest 欄位都不變 ⇒ 已安裝的機器不會看到假更新。 + version = "%s%d.%d.%d" % ((prev_prefix,) + prev) check_or_record(version, stamp) if stamp: print(" ℹ️ changelog 沒有「下一版(未發佈)」段,版本維持 %s(重打同一版)" @@ -208,7 +237,10 @@ def main(): nxt = (major, minor, 0) else: nxt = (prev[0], prev[1], prev[2] + 1) - version = "v%d.%d.%d" % nxt + # 🔴 新版一律**裸號**(leo 2026-08-17「不要 v」)。這一行就是規約真的被執行的地方: + # 往下 `heading` 寫進 changelog、`print(version)` 餵給 build-*.sh 的 `${VERSION}`, + # 所以檔名(`Arcrun-0.18.29.dmg`)與 manifest.daemon.version 同時就跟著裸了。 + version = "%d.%d.%d" % nxt if stamp: check_or_record(version, True) diff --git a/cmd/arcrun-app/daemon-version.test.mjs b/cmd/arcrun-app/daemon-version.test.mjs new file mode 100644 index 0000000..166ac45 --- /dev/null +++ b/cmd/arcrun-app/daemon-version.test.mjs @@ -0,0 +1,89 @@ +/** + * daemon-version.test.mjs — 證明**產生端**吐的是裸號(inkstone/arcrun-rag#88) + * + * 🔴 為什麼這支測試存在,而不是改一個字串常數就算: + * leo 2026-08-17 定「對外號就是三個數字,不要 v」,規約寫進了 + * InkStoneCo `system-dev/wiki/ops-facts.md`——然後 leo 隔天問「剛剛不是說不要 v?」, + * 因為 `install.arcrun.dev/api/latest` 照舊回 `daemon.version = "v0.18.28"`。 + * ⇒ **規約只活在 wiki,產生號碼的那條路照舊帶 v。** + * 這支測試釘住的就是那條路:不是「常數長什麼樣」,是「**下一次產生版本號時它是裸的**」。 + * + * 為什麼住在 `collector/` 而不是 `installer/scripts/`: + * 被測的 `daemon-version.py` 是 collector 自己的版本產生器(D95 第一輪把它的三個輸入 + * 全搬進 collector/,就是為了讓 collector/ 搬得成獨立 repo)。測試跟著被測物走, + * 拆 repo 時不會留下一支指向別人家的孤兒測試。 + * + * 跑法:node --test collector/cmd/arcrun-app/daemon-version.test.mjs + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = join(HERE, 'daemon-version.py'); + +/** + * 造一個**只有版本產生器需要的三樣東西**的假 collector 樹,然後真的跑那支腳本。 + * 刻意不是 git repo:`source_fingerprint()` 的 `git ls-files` 會失敗 → 回空字串 → 指紋閘不參與, + * 這裡要量的是「版本號長什麼樣」,不是指紋機制(它有自己的病史,不要攪在一起)。 + */ +function runProducer({ changelog, line = '0.18', stamp = false }) { + const root = mkdtempSync(join(tmpdir(), 'daemon-version-')); + const appDir = join(root, 'cmd', 'arcrun-app'); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(root, 'CHANGELOG.md'), changelog); + writeFileSync(join(root, 'DAEMON_LINE'), `${line}\n`); + copyFileSync(SCRIPT, join(appDir, 'daemon-version.py')); + try { + const stdout = execFileSync('python3', [ + join(appDir, 'daemon-version.py'), ...(stamp ? ['--stamp'] : []), + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return { version: stdout.trim(), changelogAfter: readFileSync(join(root, 'CHANGELOG.md'), 'utf8') }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const UNRELEASED = '## 下一版(未發佈)\n\n- 修了一件事\n\n'; +const OLD_V = '## v0.18.28(2026-08-16)\n\n- 舊的那一版,當初是帶 v 寫的\n'; +const NEW_BARE = '## 0.18.29(2026-08-18)\n\n- 新的那一版,裸號\n'; + +test('① 升版:下一個版本號是裸的(leo 2026-08-17「不要 v」)', () => { + const { version } = runProducer({ changelog: UNRELEASED + OLD_V }); + assert.equal(version, '0.18.29'); + assert.ok(!version.startsWith('v'), `產生端還在吐帶 v 的版本號:${version}`); +}); + +test('② 升版並戳章:寫進 changelog 的那一行也是裸的(下一輪讀到的就是裸號)', () => { + const { version, changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true }); + assert.equal(version, '0.18.29'); + assert.match(changelogAfter, /^## 0\.18\.29(\d{4}-\d{2}-\d{2})$/m); + assert.ok(!/^## v0\.18\.29(/m.test(changelogAfter), 'changelog 被戳成帶 v 的標題'); +}); + +test('③ 換版本線:新線第一版也是裸的,不是只有 patch 遞增那條路被修', () => { + const { version } = runProducer({ changelog: UNRELEASED + OLD_V, line: '0.19' }); + assert.equal(version, '0.19.0'); +}); + +test('④ 重打同一版:沿用當初的寫法,**不回頭改已發出的版本**(紅線)', () => { + // 病:若這裡吐 `0.18.28`,每一台裝著 v0.18.28 的機器都會看到一個內容一樣卻宣稱是新版的更新 + // ——selfupdate.go 的 newerThanCurrent() 是字串不等於就提示。 + const { version } = runProducer({ changelog: OLD_V }); + assert.equal(version, 'v0.18.28'); +}); + +test('⑤ 過渡會自己過期:頂上那段已是裸號時,重打也吐裸號(不必記例外)', () => { + const { version } = runProducer({ changelog: NEW_BARE + OLD_V }); + assert.equal(version, '0.18.29'); +}); + +test('⑥ 讀得懂歷史:整份 changelog 都是舊的帶 v 寫法時,仍算得出下一版(且裸)', () => { + const onlyOld = UNRELEASED + '## v0.18.28(2026-08-16)\n\n- a\n\n## v0.18.27(2026-08-15)\n\n- b\n'; + const { version } = runProducer({ changelog: onlyOld }); + assert.equal(version, '0.18.29'); +});