fix(ship): 解開出貨線的兩個自鎖——閘改成量指紋、戳版順序倒過來(arcrun-rag#88)
出貨線從第 5 站 daemon-source-check 就把自己鎖死,push/deploy/verify/ release-record 四站一次都沒執行過。實查真兇是**兩個**,不是一個,而且同源: D95 第一輪把 `CHANGELOG.md` 搬進 `collector/`(為了讓 collector/ 自足、 有資格獨立成 repo)之後,**「宣告新版本」這個動作本身就是在改 collector/**。 ── 自鎖① 出貨線那道閘:用 git 歷史當代理 ──────────────────────────── 舊判法=`git log <宣告那顆>..HEAD -- collector` 非空就擋。 ⇒ 一顆**只改宣告、一行程式都沒動**的 commit(dcd0132 就是)也被算成 「宣告之後源碼又動過」⇒ 閘擋自己。 ⇒ 反過來也不準:collector/ 底下有些檔案(打包腳本旁的 .mjs 工具、README) 根本不會進到執行檔,動了它們一樣被判「要重打包」。 🔴 兩件事都對,疊起來變成死結:**衝突在範圍重疊,不在任何一方做錯。** 修法不是把 changelog 搬回去(那會推翻 D95 第一輪),也不是加路徑白名單 (那就是被禁的字串比對)。改成問**同一件事實**,而那份事實這個 repo 早就在算: `daemon-version.py` 每次戳版都把「當下的原始碼指紋」記進 `.version-source.json`(2026-08-06 起就存在)。 **版本 X 的指紋 == 現在算出來的 ⇒ X 的成品確實是照這份源碼打的。** 宣告那一步改到的檔案,在戳版當下就已經算進指紋 ⇒ 結構上不可能擋自己; 而「改了 code 卻沒重打包」照樣指紋不同 ⇒ 原本要擋的一個都沒放過。 指紋怎麼算是 daemon 自己的知識,出貨線**不重寫一份**(兩套並存必然漂移): 新增唯讀的 `daemon-version.py --source-state`,`daemon-freshness.mjs` 只負責問與判。 這就是遷移計畫寫的「daemon-freshness 該**搬家**不是加固」。 ── 自鎖② 戳版的順序:帳本記的是「還沒戳版」那一刻的樹 ────────────── `main()` 原本先 `check_or_record()` 再把宣告寫進 changelog。而 changelog 就住在 指紋涵蓋的樹底下 ⇒ 帳本記下來的那棵樹,從寫完宣告那一刻起就不存在了。 實撞(本輪 A/B 重現,見 daemon-version.test.mjs ⑦): build-mac.sh 戳版打完 dmg → 同一輪 build-win.sh 走「重打同一版」路徑 → 現在的指紋(含已戳版的 changelog)≠ 帳本裡那個 → dist/ 已有 dmg → 判「版號已對應另一份原始碼」→ **第二個平台永遠打不出來** 而上游 `daemon-sync` 兩個平台都要,缺一不准出貨 ⇒ 出貨線在這裡也走不完。 ⇒ 先寫宣告、再記指紋。(這條路是升版,帳本不可能已有該版號 ⇒ 不會中途 die。) 🔴 這個病是 D95 第一輪帶進來的:v0.18.29 打包時 changelog 還在 docs-site/, 不在指紋範圍內,所以當時不會發作。 ── 為什麼要多一本逐檔帳(.version-source-files.json)──────────────── 總指紋只有 16 個字,對不上時只講得出「不一樣」。而紅線要求 「回報通過時要說得出它實際比對了什麼」⇒ 逐檔雜湊讓閘講得出**哪幾個檔**變了。 只留最新一版(閘只問最上面那一版),與總指紋帳本一樣排除在指紋之外(自我參照)。 ── 閘要留痕(InkStoneCo#48)──────────────────────────────────────── `installer/daemon-freshness-gate-log.md`:擋下、放行、明知故犯放行,三種都記。 ── 順手收進來的一筆(同一輪的複驗項)─────────────────────────────── cherry-pick 89dd323:`checkDocsLive()` 的假綠。實測線上 stage 文件站—— 那一頁是舊的(135KB 逐版列表、沒有 dcd0132 的 meta-refresh),卻因為內文 剛好含一次 releases 網址而被 `body.includes()` 判過 ⇒ fails=0。修後 fails=1。 實測 · daemon 0.18.30:Mac dmg 8.3M + Windows exe 22M **同一輪打出來**(修前不可能) · 閘判決:status=ok,帳本 ad1106716ae426d2 == 現在 ad1106716ae426d2(涵蓋 228 個檔) · installer 測試 257 支全綠;collector 版本產生器 9 支全綠(新增 ⑦⑧⑨) · daemon-freshness 自己的演練 15 支:兩個方向各有案例 (不擋自己/仍擋得住沒重打包/問不出來一律停/留痕) 紅線遵守:只碰 stage;沒推 main、沒併分支、沒碰 prod/GitHub。 Refs: inkstone/arcrun-rag#88 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,17 @@ LINE_FILE = COLLECTOR / "DAEMON_LINE"
|
||||
UNRELEASED = "## 下一版(未發佈)"
|
||||
# 版本 → 當時原始碼指紋。用來擋「同一個版號、不同的執行檔」。
|
||||
SOURCE_LOCK = HERE / ".version-source.json"
|
||||
# 🔴 2026-08-18(inkstone/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 → 4(2026-08-18):指紋的**根**從 repo 根換成 collector/,被雜湊的相對路徑
|
||||
# 因此全部改變(`collector/cmd/…` → `cmd/…`)⇒ 舊帳本是「用不同單位量出來的數字」,
|
||||
@@ -123,12 +134,25 @@ def source_fingerprint():
|
||||
差別只有相對路徑前綴——而路徑有進雜湊,所以 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)
|
||||
return "", {} # 不在 git 裡就不擋(例如從 tarball 解出來 build)
|
||||
# 🔴 2026-08-06 三修:**帳本自己不能算進指紋**(經典的自我參照)。
|
||||
# `.version-source.json` 就住在 collector/ 底下 ⇒ 戳版號寫入它,指紋就變
|
||||
# ⇒ 同一輪連續打 win/mac/msix,第二支就被自己的閘擋下(實撞)。
|
||||
@@ -136,16 +160,23 @@ def source_fingerprint():
|
||||
# dist/ 也早就 gitignore,不在名單裡。真兇只有帳本。)
|
||||
# 只排除帳本一個檔——**不要順手把 build/ 整個排掉**,
|
||||
# 那會讓「換 app icon」不算原始碼變更,等於把閘挖個洞。
|
||||
skip = {str(SOURCE_LOCK.relative_to(COLLECTOR))}
|
||||
# 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(fp.read_bytes())
|
||||
return h.hexdigest()[:16]
|
||||
h.update(data)
|
||||
per_file[rel] = hashlib.sha256(data).hexdigest()
|
||||
return h.hexdigest()[:16], per_file
|
||||
|
||||
|
||||
def artifacts_for(version):
|
||||
@@ -159,7 +190,7 @@ def artifacts_for(version):
|
||||
|
||||
def check_or_record(version, stamp):
|
||||
"""同一個版號只准對應一份原始碼;不同就擋下並說怎麼辦。"""
|
||||
fp = source_fingerprint()
|
||||
fp, per_file = scan_source()
|
||||
if not fp:
|
||||
return
|
||||
lock = json.loads(SOURCE_LOCK.read_text()) if SOURCE_LOCK.exists() else {}
|
||||
@@ -196,9 +227,98 @@ def check_or_record(version, stamp):
|
||||
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#88,2026-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)
|
||||
@@ -261,11 +381,29 @@ def main():
|
||||
version = "v%d.%d.%d" % nxt
|
||||
|
||||
if stamp:
|
||||
check_or_record(version, True)
|
||||
# 🔴 2026-08-18(inkstone/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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user