Files
arcrun-collector/cmd/arcrun-app/daemon-version.py
T
Leo 9299ec8960 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
2026-08-18 16:33:27 +08:00

416 lines
23 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""daemon-version.py — daemon 版本號的唯一產生器(2026-08-06 二版)
🔴 leo 08-06:「給版本號、本版更新內容、打包產品、顯示在前端,
這一整串都應該是機械化,**不應該每次手工做**」
── 病根(實測)────────────────────────────────────────────────────
manifest 宣告 daemon v0.18.5,線上產物拆開卻是 v0.18.4。
原因:本 repo **一個 git tag 都沒有**build 腳本寫的是 `git describe --tags`
⇒ 拿不到 v0.18.x ⇒ 版本只能靠打包時人工 `VERSION=v0.18.4 ./build-win.sh` 敲。
人工敲 ⇒ 兩條打包線(win/mac)各敲各的 ⇒ manifest 說謊。
── 為什麼不是「數 commit 數」(一版的做法,已否決)─────────────────
第一版用「動過 collector/ 的 commit 數」當 patch。確實不用手打,
但**每個 commit 都變成一版**(一天 5 個 commit 就跳 5 版),
而且每跳一版就要補一段 changelog——把機械化變成新的手工活。
── 🔴 2026-08-18(D95 第一輪):所有輸入都搬進 collector/ 了 ────────────
leo 08-17:「身為管理者,你要從頭到尾**不要有很多扭曲**,因為你根本不記得
你做的這些扭曲,**每次都要查**,很直接,源碼、產出物,從 stage 到 prod。」
本腳本原本往 **repo 根**伸手拿三樣東西:
① docs-site/src/content/docs/help/changelog.md(版本與更新說明)
② ROOT/DAEMON_LINE(版本線)
③ `git ls-files collector`(原始碼指紋,從 repo 根算)
⇒ `collector/` 算不出自己的版本,也**沒有資格被搬成獨立 repo**。
現在三樣全在 `collector/` 底下:`CHANGELOG.md``DAEMON_LINE`/指紋以 collector 為根。
本檔一律以**自己的位置**定位(`__file__` 往上兩層=collector/),
**不再呼叫 `git rev-parse --show-toplevel`**——那是往上伸手的入口。
(三道既有修補全部原樣保留:指紋演算法版本作廢重記、帳本自我參照排除、
沒有產物的版號可重戳。只有「以什麼為根」變了。)
── 現在的做法:版本由 changelog 決定,且不用手打數字 ──────────────
單一真相源= collector/CHANGELOG.md(用戶語言那份)。
· 要出新版 ⇒ 在檔案最上面加一段標題 `## 下一版(未發佈)`,底下寫白話更新內容。
· 打包時本腳本把它**戳成正式版號**(上一版 patch + 1)並補上今天日期。
· 沒有「未發佈」段 ⇒ 版本=最上面那一版(重打同一版,不會虛增)。
⇒ **版本號從頭到尾沒有人打過**,而且「更新內容」與「版本」天生綁在一起——
不可能出現「版本升了但沒人知道改了什麼」,也不可能「寫了內容卻忘了升版」。
⇒ 不需要 git tagActions/輪詢(守 D20 紅線)。
換線(0.18 → 0.19):改 `DAEMON_LINE`,下一版就從 0.19.0 開始。
用法:
daemon-version.py # 印出版本(唯讀,不改檔)
daemon-version.py --stamp # 把「下一版(未發佈)」戳成正式版號+日期後印出
"""
import datetime
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent # collector/cmd/arcrun-app
# 🔴 daemon 的根=`collector/`,用**自己的位置**推出來,不問 git 也不問 repo 根。
# 這一行就是「臍帶剪掉了」的本體:往上兩層剛好是 collector/,再往上一步都不走。
COLLECTOR = HERE.parents[1]
CHANGELOG = COLLECTOR / "CHANGELOG.md"
LINE_FILE = COLLECTOR / "DAEMON_LINE"
UNRELEASED = "## 下一版(未發佈)"
# 版本 → 當時原始碼指紋。用來擋「同一個版號、不同的執行檔」。
SOURCE_LOCK = HERE / ".version-source.json"
# 🔴 2026-08-18inkstone/arcrun-rag#88):**最新一次戳版當下,逐檔各自的雜湊**。
#
# 為什麼要多這一份:`SOURCE_LOCK` 只記一個 16 字的總指紋 ⇒ 對不上時只講得出
# 「不一樣」,講不出「**哪個檔**不一樣」。而出貨線那道閘
# `installer/scripts/daemon-freshness.mjs`)被明文要求
# 「回報通過時要說得出它實際比對了什麼」——只有一個總指紋是說不出來的。
#
# 只留**最新一版**:閘唯一會問的問題是「changelog 最上面那一版的成品,
# 是不是照現在這份源碼打的」,歷史版本沒人會回頭問 ⇒ 不會無限長大(約 20KB)。
# 與 `SOURCE_LOCK` 一樣**排除在指紋之外**(自我參照,見 scan_source 的 skip)。
SOURCE_FILES = HERE / ".version-source-files.json"
# 指紋演算法版本:改算法時 +1,帳本會自動作廢重記(見 check_or_record)。
# 🔴 3 → 42026-08-18):指紋的**根**從 repo 根換成 collector/,被雜湊的相對路徑
# 因此全部改變(`collector/cmd/…` → `cmd/…`)⇒ 舊帳本是「用不同單位量出來的數字」,
# 比對沒有意義。照既有設計改號讓它自動整本作廢重記,不要手改 JSON。
FINGERPRINT_ALGO = 4
# 🔴 2026-08-18inkstone/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):
print(msg, file=sys.stderr)
sys.exit(1)
def source_fingerprint():
"""會被編進執行檔的原始碼指紋——直接對**工作區檔案內容**取值。
為什麼要這道閘(2026-08-06):
戳完版號後又改 code、再打一次包,腳本判定「沒有未發佈段=重打同一版」
⇒ 兩個內容不同的執行檔都叫 v0.18.9。
機械產生版本號還不夠,還要保證同一個版號只對應一份原始碼。
二修(同日,閘擋錯自己人):
原本算「HEAD:collector 樹雜湊 + 未提交 diff」,但那在 commit 前後
會給出不同指紋、即使內容一模一樣——戳版號時檔案還沒提交(在 diff 裡),
提交後樹變了、diff 空了。結果只改 wiki 也被判「版號已對應另一份原始碼」。
改成對工作區檔案內容取值,與有沒有提交無關。
四修(2026-08-18D95 第一輪):
改成**以 `collector/` 為根**列檔(cwd=COLLECTOR、pathspec `.`),
不再從 repo 根列 `collector` 這個子目錄。涵蓋的檔案集合完全相同,
差別只有相對路徑前綴——而路徑有進雜湊,所以 FINGERPRINT_ALGO 跟著 +1。
這樣 collector/ 搬成獨立 repo 之後,同一段程式碼算出的仍是同一個值。
"""
return scan_source()[0]
def scan_source():
"""走訪一次原始碼樹,同時交出「總指紋」與「逐檔雜湊」。
🔴 **一次走訪、兩個產物**是刻意的:兩者若各走各的,就可能量到不同的檔案集合
(例如中間有人存檔),而那種不一致查起來會像鬼故事。
回傳 `(fingerprint, {相對路徑: sha256})`;不在 git 裡就回 `("", {})`。
總指紋的算法**一個位元都沒動**`h.update(rel)` + `h.update(內容)`,路徑排序),
所以 `FINGERPRINT_ALGO` 維持 4、既有帳本繼續有效。
"""
try:
files = subprocess.check_output(
["git", "ls-files", "-co", "--exclude-standard", "."],
cwd=COLLECTOR, text=True).split("\n")
except subprocess.CalledProcessError:
return "", {} # 不在 git 裡就不擋(例如從 tarball 解出來 build
# 🔴 2026-08-06 三修:**帳本自己不能算進指紋**(經典的自我參照)。
# `.version-source.json` 就住在 collector/ 底下 ⇒ 戳版號寫入它,指紋就變
# ⇒ 同一輪連續打 win/mac/msix,第二支就被自己的閘擋下(實撞)。
# (查證過:gen-icons.py 產的 .ico 是**位元一致**的,不是它造成的;
# dist/ 也早就 gitignore,不在名單裡。真兇只有帳本。)
# 只排除帳本一個檔——**不要順手把 build/ 整個排掉**,
# 那會讓「換 app icon」不算原始碼變更,等於把閘挖個洞。
# 2026-08-18:逐檔帳本(SOURCE_FILES)是同一個形狀的自我參照,一起排除。
skip = {
str(SOURCE_LOCK.relative_to(COLLECTOR)),
str(SOURCE_FILES.relative_to(COLLECTOR)),
}
h = hashlib.sha256()
per_file = {}
for rel in sorted(f for f in files if f.strip() and f not in skip):
fp = COLLECTOR / rel
if not fp.is_file():
continue # 已刪除的檔案
data = fp.read_bytes()
h.update(rel.encode())
h.update(data)
per_file[rel] = hashlib.sha256(data).hexdigest()
return h.hexdigest()[:16], per_file
def artifacts_for(version):
"""磁碟上有沒有這個版號的產物(exe/dmg/msix)。有=真的出過東西,不准重戳。"""
dist = HERE / "dist"
if not dist.is_dir():
return []
return [f.name for f in dist.iterdir()
if version in f.name and f.suffix in (".exe", ".dmg", ".msix", ".zip")]
def check_or_record(version, stamp):
"""同一個版號只准對應一份原始碼;不同就擋下並說怎麼辦。"""
fp, per_file = scan_source()
if not fp:
return
lock = json.loads(SOURCE_LOCK.read_text()) if SOURCE_LOCK.exists() else {}
# 指紋演算法一改,舊紀錄就是「用不同單位量出來的數字」,比對沒有意義。
# 帳本自己記演算法版本,不合就整本作廢重記——比留著誤擋自己人好。
if lock.get("_algo") != FINGERPRINT_ALGO:
if lock:
print("i 指紋演算法已更新(v%s),舊紀錄作廢重記" % FINGERPRINT_ALGO,
file=sys.stderr)
lock = {"_algo": FINGERPRINT_ALGO}
known = lock.get(version)
# 🔴 2026-08-06:打包**失敗**時版號已被戳、產物卻沒生出來 ⇒ 那個版號被白白燒掉,
# 修完再打就被自己的閘擋下,只能去手改 JSON(爛示範,遲早有人順手改成「都放行」)。
# ⇒ 沒有任何產物的版號=**從未出貨**,本來就該可以重戳。
# 判準是「磁碟上有沒有這個版號的產物」,不是「我覺得它沒出貨」。
if known and known != fp and not artifacts_for(version):
print("%s 之前戳過但**沒有產出任何檔案**(打包失敗),視為未出貨 → 重新對應這次的原始碼"
% version, file=sys.stderr)
known = None
lock.pop(version, None)
if known and known != fp:
die("""❌ %s 這個版號已經對應過另一份原始碼了(記錄 %s,現在 %s)。
代表你在戳完版號之後又改了 code。再打一次會產生
**兩個內容不同、卻都叫 %s 的執行檔** —— 那就是「版本號說謊」。
要出新版:在 changelog 最上面加一段
## 下一版(未發佈)
- (用戶語言的更新內容)
然後重跑打包,版號會自動變成下一個。""" % (version, known, fp, version))
if stamp and lock.get(version) != fp:
lock[version] = fp
SOURCE_LOCK.write_text(json.dumps(lock, indent=1, ensure_ascii=False) + "\n")
# 逐檔帳本永遠寫成「**最新一次戳版**當下的樣子」——即使總指紋沒變(重打同一版),
# 也要覆蓋,否則它會停在更早的版本、讓 `--source-state` 講不出差異。
if stamp:
SOURCE_FILES.write_text(json.dumps(
{"_algo": FINGERPRINT_ALGO, "version": version,
"fingerprint": fp, "files": per_file},
indent=1, ensure_ascii=False, sort_keys=False) + "\n")
def source_state():
"""**唯讀**回答一個問題:「changelog 最上面那一版的成品,是不是照現在這份源碼打的?」
── 為什麼這個回答要住在這裡(inkstone/arcrun-rag#882026-08-18)──────────
出貨線本來自己回答這題,做法是「找出宣告那一版的 commit,看之後 `collector/`
有沒有再被 commit」。那是**用路徑與 git 歷史當代理**,不是量事實,於是:
· 「宣告新版本」這個動作本身就要改 `collector/CHANGELOG.md`
(D95 第一輪搬進來的,為的是讓 collector/ 自足)
⇒ 只改宣告、什麼程式都沒動的 commit,也被算成「源碼又動過」⇒ **閘擋自己**。
· 反過來,`collector/` 底下有些檔案(例如打包腳本旁的 .mjs 工具)
根本不會進到執行檔裡,動了它們也被算成「要重打包」。
真正該問的是**同一件事實**,而那份事實這支腳本本來就在算:
`check_or_record()` 每次戳版都把「當下的原始碼指紋」記進帳本。
⇒ 版本 X 的指紋 == 現在算出來的指紋,就代表 **X 的成品確實是照這份源碼打的**。
「宣告」那一步改到的檔案,在戳版當下就已經算進去了 ⇒ 結構上不可能擋自己。
所以這一題**搬家**到源碼這一邊回答(遷移計畫早就寫過「該搬家不是加固」),
出貨線只負責問與擋。輸出 JSON,欄位全部是量到的值,判斷留給問的人。
"""
fp, per_file = scan_source()
text = CHANGELOG.read_text() if CHANGELOG.exists() else ""
top = RELEASED_RE.search(text)
version = ("%s%s.%s.%s" % top.groups()) if top else None
lock = {}
if SOURCE_LOCK.exists():
try:
lock = json.loads(SOURCE_LOCK.read_text())
except ValueError:
lock = {}
ledger_algo = lock.get("_algo")
# 演算法一改,舊紀錄就是「用不同單位量出來的數字」⇒ 不當成有紀錄(同 check_or_record)。
recorded = lock.get(version) if (version and ledger_algo == FINGERPRINT_ALGO) else None
prev_files, files_version, files_algo = {}, None, None
if SOURCE_FILES.exists():
try:
doc = json.loads(SOURCE_FILES.read_text())
files_algo = doc.get("_algo")
files_version = doc.get("version")
if files_algo == FINGERPRINT_ALGO:
prev_files = doc.get("files") or {}
except ValueError:
pass
changed, added, removed = [], [], []
comparable = bool(prev_files) and files_version == version
if comparable:
for rel, sha in sorted(per_file.items()):
if rel not in prev_files:
added.append(rel)
elif prev_files[rel] != sha:
changed.append(rel)
removed = sorted(r for r in prev_files if r not in per_file)
return {
"algo": FINGERPRINT_ALGO,
"collector_dir": str(COLLECTOR),
"changelog": str(CHANGELOG),
"version": version,
"has_unreleased": UNRELEASED in text,
"current_fingerprint": fp or None,
"ledger_algo": ledger_algo,
"recorded_fingerprint": recorded,
"file_count": len(per_file),
"files_ledger_version": files_version,
"files_ledger_algo": files_algo,
"comparable_per_file": comparable,
"changed": changed,
"added": added,
"removed": removed,
"artifacts": sorted(artifacts_for(version)) if version else [],
}
def main():
if "--source-state" in sys.argv:
# 🔴 唯讀:不戳版、不寫任何檔案。出貨線的閘就是靠這條路問事實的。
json.dump(source_state(), sys.stdout, ensure_ascii=False, indent=1)
sys.stdout.write("\n")
return
stamp = "--stamp" in sys.argv
if not CHANGELOG.exists():
die("❌ 找不到 changelog%s" % CHANGELOG)
line = LINE_FILE.read_text().strip() if LINE_FILE.exists() else "0.18"
if not re.fullmatch(r"\d+\.\d+", line):
die("❌ DAEMON_LINE 必須是 MAJOR.MINOR(例 0.18),現在是 %r" % line)
text = CHANGELOG.read_text()
top = RELEASED_RE.search(text)
if not top:
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():
die("❌ `%s` 必須放在所有已發佈版本的**上面**(它代表還沒出的那一版)" % UNRELEASED)
if not has_unreleased:
# 沒有待發佈內容=重打同一版。冪等,不虛增。
# 🔴 沿用那一版**當初的書寫形式**:重打 `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(重打同一版)"
% version, file=sys.stderr)
print(version)
return
# 有待發佈內容 ⇒ 升版。換線時從該線的 .0 開始。
major, minor = (int(x) for x in line.split("."))
if (major, minor) != (prev[0], prev[1]):
nxt = (major, minor, 0)
else:
nxt = (prev[0], prev[1], prev[2] + 1)
# 🔴 **裸號就是從這一行開始的**(leo 2026-08-17「對外號就是三個數字,不要 v」)。
#
# 這一行往下走 `heading` 寫進 changelog、`print(version)` 餵給 build-*.sh 的
# `${VERSION}` ⇒ 檔名(`Arcrun-0.18.x.dmg`)與 `manifest.daemon.version`
# ⇒ `install.arcrun.dev/api/latest` 的 `daemon.version`。
# **leo 看到並質問的那個 `v`,就是從這一行長出去的**,所以也只能從這一行拿掉。
#
# ── 為什麼 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+`,見 `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):**順序反過來了,這是第二個自鎖。**
#
# 原本是「先記指紋,再把宣告寫進 changelog」。而 changelog 就住在指紋涵蓋的
# 那棵樹底下(D95 第一輪搬進 collector/),所以記下來的是**還沒戳版**那一刻的
# 指紋——它描述的那棵樹,從寫完宣告的那一刻起就不存在了。
#
# 實撞(A/B 重現,見 daemon-version.test.mjs ⑨):build-mac.sh 戳完版打好 dmg
# 同一輪接著跑 build-win.sh ⇒ 沒有草稿段 ⇒ 走「重打同一版」路徑 ⇒ 現在的指紋
# (含已戳版的 changelog)≠ 帳本裡那個(戳版前的)⇒ 而 dist/ 已經有 dmg
# ⇒ 判「版號已對應另一份原始碼」⇒ **第二個平台永遠打不出來**。
# 而 ship.mjs 的 daemon-sync 兩個平台都要,缺一就不准出貨。
#
# ⇒ 先把宣告寫下去,再記指紋。帳本從此描述的是「打包當下真正的那棵樹」,
# 這也正是出貨線那道閘(daemon-freshness.mjs)要拿來比對的東西。
# 安全性:這條路是**升版**,帳本裡不可能已經有這個版號 ⇒ check_or_record
# 不會在這裡 die,不存在「changelog 已改、卻中途失敗」的半套狀態。
# 🔴 這個病是 D95 第一輪帶進來的:v0.18.29 打包時 changelog 還在 docs-site/
# 不在指紋範圍內,所以當時不會發作。
today = datetime.date.today().isoformat()
heading = "## %s%s" % (version, today)
CHANGELOG.write_text(text.replace(UNRELEASED, heading, 1))
print(" ✅ changelog:「下一版(未發佈)」→ %s" % heading, file=sys.stderr)
check_or_record(version, True)
print(version)
if __name__ == "__main__":
main()