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:
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user