feat(version): 對外號真的不帶 v + release 掛得動成品 + 內外對應由機器產生(arcrun-rag#88)

leo 2026-08-18 當場抓到「剛剛不是說不要 v?」——實查確認不是措辭問題:
規約寫在 wiki,而**產生號碼的那條路照舊帶 v**。這輪修的是產地與載體。

① 對外號從**產生端**就是裸的
   `daemon-version.py` 是 daemon 那條線唯一的版本產生器,一路長到
   產物檔名(Arcrun-<版本>.dmg)、manifest.daemon.version、/api/latest。
   - 新戳的版本一律裸號;`RELEASED_RE` 加 `v?` 讀得懂歷史
   - 🔴 重打同一版**沿用 changelog 上原本的寫法**:selfupdate.go 的
     newerThanCurrent() 是字串不等於就提示更新 ⇒ 把 v0.18.28 改吐成 0.18.28
     會讓每台已安裝的機器收到一個內容相同卻宣稱是新版的**假更新**。
     規約會自己過期:頂上那段變裸號之後,重打路徑吐的也是裸號。
   - 實測:真實 changelog +一段未發佈 → 產生器吐 `0.18.30`(不是 v0.18.30)

② release 掛得動真的成品(本輪的硬前置)
   leo:「assets 都寫 source code,這兩個附檔實際是什麼?是 dmg 還是 go?」
   ——那是 Gitea/GitHub 自動產生的整包 repo 快照。實測既有 release 的
   attachment 數是 **0**,坐實那兩個附檔沒有一個是成品。
   - gitea/github-release.mjs 各補 uploadReleaseAsset/listReleaseAssets
     (兩邊 API 形狀不同是 API 本身的差別,不硬套成同一種)
   - release-lines.mjs 讓**每條版本線各自宣告**自己的成品(assetKeys);
     daemon 線明列 mac/win/msix,**排除釘在 0.15.7 的兩筆歷史遺留**
     ——照「有 file 欄就掛」會把舊檔掛上新版頁面,正是本輪要治的病
   - ship.mjs 建完 release 後掛檔,並**回頭查證**附件真的在頁面上;
     缺任何一個就擋(不留「點下去沒東西」的版本頁)

③ 內外對應由機器產生,不再是總管手寫
   leo:「對應關係記在該次發佈的 release note,不靠號碼本身編碼」
   - 新增 internal-version.mjs:`RAG-20260818-001-<commit7>`
   - 🔴 `formatInternalVersion` 收到空的 artifacts 直接丟例外
     ⇒ **算不出一個沒有成品的內部號**(leo:「內部每次就要出貨 bundle 的版本」)。
     ops-facts 原本記著「這件事目前靠自律,沒有機械閘」——這就是那道閘
   - 序號從**既有 release 的事實**算,不養會漂的帳;tag 與內文都掃
     (08-17 那兩筆內部號寫在 tag_name 上,只掃內文當天會從 1 重編、無聲撞號)

紅線遵守:既有 tag/檔名一個都沒回頭改;沒寫 GitHub(②③ 實測都在 Gitea);
沒部署、沒推 main、沒自己併分支。演練建的 release 與 tag 都已刪除複驗。

測試:installer 252 支全綠(原 233 +新 19);collector 產生器新增 6 支全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:13:00 +08:00
parent 3af210e03a
commit 2e26528bc9
2 changed files with 126 additions and 5 deletions
+37 -5
View File
@@ -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-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):
@@ -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)
+89
View File
@@ -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.282026-08-16\n\n- 舊的那一版,當初是帶 v 寫的\n';
const NEW_BARE = '## 0.18.292026-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.282026-08-16\n\n- a\n\n## v0.18.272026-08-15\n\n- b\n';
const { version } = runProducer({ changelog: onlyOld });
assert.equal(version, '0.18.29');
});