Files
arcrun-collector/cmd/arcrun-app/daemon-version.test.mjs
T
Leo 03a2c3d5f8 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>
2026-08-18 15:39:52 +08:00

182 lines
10 KiB
JavaScript
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.
/**
* daemon-version.test.mjs — 證明**產生端**吐的是裸號(inkstone/arcrun-rag#88
*
* 🔴 **這支測試現在釘的是「還沒做到」,不是「做到了」**(誠實優先於好看)。
*
* leo 2026-08-17 定「對外號就是三個數字,不要 v」;隔天他問「剛剛不是說不要 v?」,
* 因為 `install.arcrun.dev/api/latest` 照舊回 `daemon.version = "v0.18.28"`。
* 那個 `v` 的產地就是本目錄的 `daemon-version.py`。
*
* 2026-08-18 實際改成裸號,當場撞到:**那個 `v` 目前身兼「哪一條版本線」的判別器**
* (三支閘+`changelogRelFor()` 靠它認出「這份 changelog 是不是 daemon 的」)。
* 一改裸號,daemon 的閘就把雲端那條 `## 1.4.47(…)` 當成 daemon 版本撈走
* ⇒ `installer/scripts/daemon-in-bundle-gate.test.mjs` 的 ①⑮⑯ 轉紅,
* 而它們釘的正是 D95 第二輪剛修好的「指到錯的 changelog 卻安靜放行」。
*
* ⇒ 所以本檔的測試**斷言的是現況(一律吐 `v`)**,並把「為什麼還不能改」釘成
* 一支會說話的測試。等替代判別器就位(見 `daemon-version.py` 那段註解與
* `inkstone/arcrun-rag#88`),把 ①②③ 的期望值改成裸號,這支就變成驗收。
*
* 為什麼住在 `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- 舊的那一版\n';
test('① 升版:**目前**仍吐帶 v 的版本號(leo 要的裸號尚未落到產生端)', () => {
const { version } = runProducer({ changelog: UNRELEASED + OLD_V });
assert.equal(version, 'v0.18.29');
// 🔴 這一行是給未來的人看的:改成裸號之前,先讀 daemon-version.py 裡
// `version = "v%d.%d.%d" % nxt` 上方那段——單獨改這裡會讓 manifest 說謊。
});
test('② 升版並戳章:寫進 changelog 的標題也帶 v(兩邊一致,不會自我矛盾)', () => {
const { version, changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
assert.equal(version, 'v0.18.29');
assert.match(changelogAfter, /^## v0\.18\.29\d{4}-\d{2}-\d{2}$/m);
});
test('③ 換版本線:新線第一版(同樣帶 v)', () => {
const { version } = runProducer({ changelog: UNRELEASED + OLD_V, line: '0.19' });
assert.equal(version, 'v0.19.0');
});
test('④ 重打同一版:冪等,不虛增(原有行為,未動)', () => {
const { version } = runProducer({ changelog: OLD_V });
assert.equal(version, 'v0.18.28');
});
test('⑤ 🔴 判別器還在:daemon 的判斷式**不可以**撈到雲端那條線的版號', () => {
// 這支就是「為什麼還不能改裸號」的本體。`v` 目前是兩條版本線唯一的區分方式:
// installer/scripts/daemon-notes.mjs 的 DAEMON_RELEASED_RE 只認 `^## vX.Y.Z`。
// 放寬成 v? 之後,`## 1.4.47(…)` 會被 daemon 的閘當成 daemon 版本。
const CLOUD = '## 1.4.472026-08-15\n\n- 雲端那條線\n';
const strict = /^## (v\d+\.\d+\.\d+)/m;
const loose = /^## (v?\d+\.\d+\.\d+)/m;
assert.equal(CLOUD.match(strict), null, '嚴格版正確地認不出雲端版號');
assert.equal(CLOUD.match(loose)[1], '1.4.47', '放寬版會撈到雲端版號 ⇒ 這就是那三支閘轉紅的原因');
});
test('⑥ 產生端與那條判斷式對得上(兩份實作靠測試對齊,不靠註解)', () => {
const { version } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
const { changelogAfter } = runProducer({ changelog: UNRELEASED + OLD_V, stamp: true });
const strict = /^## (v\d+\.\d+\.\d+)/m;
assert.equal(changelogAfter.match(strict)[1], version,
'產生端戳出來的標題,必須被 installer 那三支閘的判斷式讀得回同一個版本');
});
// ═══════════════════════════════════════════════════════════════════════════
// 指紋帳本:戳版的**順序**inkstone/arcrun-rag#882026-08-18
//
// 上面那些案例刻意避開指紋機制(假樹不是 git repo ⇒ 指紋回空字串)。
// 下面三支相反:**一定要是 git repo**,因為要驗的就是指紋帳本本身。
// ═══════════════════════════════════════════════════════════════════════════
/** 造一個像真的一樣的 collector 樹:是 git repodist/ 被 gitignore(與真 repo 一致)。 */
function realishTree({ changelog, line = '0.18' }) {
const root = mkdtempSync(join(tmpdir(), 'daemon-version-git-'));
const appDir = join(root, 'collector', 'cmd', 'arcrun-app');
mkdirSync(appDir, { recursive: true });
const git = (...a) => execFileSync('git', a, { cwd: root, encoding: 'utf8' });
git('init', '-q', '-b', 'main');
writeFileSync(join(root, 'collector', 'CHANGELOG.md'), changelog);
writeFileSync(join(root, 'collector', 'DAEMON_LINE'), `${line}\n`);
writeFileSync(join(appDir, 'main.go'), 'package main\n');
writeFileSync(join(appDir, '.gitignore'), 'dist/\nbuild/bin\n');
copyFileSync(SCRIPT, join(appDir, 'daemon-version.py'));
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '-m', 'init');
const stampIt = () => execFileSync('python3', [join(appDir, 'daemon-version.py'), '--stamp'],
{ cwd: appDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
return { root, appDir, stampIt, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
test('⑦ 🔴 同一輪打三個平台不准被自己的閘擋下(指紋要記「戳版之後」那棵樹)', () => {
// 病(2026-08-18 A/B 重現):原本是「先記指紋、再把宣告寫進 changelog」,而 changelog
// 就住在指紋涵蓋的樹底下(D95 第一輪搬進 collector/)⇒ 帳本記的是**還沒戳版**那一刻。
// build-mac.sh 打完 dmg,同一輪的 build-win.sh 走「重打同一版」路徑,
// 現在的指紋 ≠ 帳本裡那個 ⇒ 判「版號已對應另一份原始碼」⇒ **第二個平台永遠打不出來**。
// 而 ship.mjs 的 daemon-sync 兩個平台都要,缺一不准出貨。
const t = realishTree({ changelog: UNRELEASED + OLD_V });
try {
assert.equal(t.stampIt(), 'v0.18.29', '① mac');
mkdirSync(join(t.appDir, 'dist'), { recursive: true });
writeFileSync(join(t.appDir, 'dist', 'Arcrun-v0.18.29.dmg'), 'x');
assert.equal(t.stampIt(), 'v0.18.29', '② win(同一輪第二支打包線)');
writeFileSync(join(t.appDir, 'dist', 'Arcrun-win-v0.18.29.exe'), 'x');
assert.equal(t.stampIt(), 'v0.18.29', '③ msix(同一輪第三支)');
} finally { t.cleanup(); }
});
test('⑧ 🔴 但「戳完版之後真的改了 code」照樣要被擋(別把閘修成永遠放行)', () => {
const t = realishTree({ changelog: UNRELEASED + OLD_V });
try {
assert.equal(t.stampIt(), 'v0.18.29');
mkdirSync(join(t.appDir, 'dist'), { recursive: true });
writeFileSync(join(t.appDir, 'dist', 'Arcrun-v0.18.29.dmg'), 'x');
writeFileSync(join(t.appDir, 'main.go'), 'package main\n// 戳完版才改的\n');
assert.throws(() => t.stampIt(), /已經對應過另一份原始碼/);
} finally { t.cleanup(); }
});
test('⑨ --source-state 是唯讀的,而且答得出「這一版配不配得上現在這棵樹」', () => {
const t = realishTree({ changelog: UNRELEASED + OLD_V });
try {
t.stampIt();
const ask = () => JSON.parse(execFileSync('python3',
[join(t.appDir, 'daemon-version.py'), '--source-state'],
{ cwd: t.appDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
const before = readFileSync(join(t.appDir, '.version-source.json'), 'utf8');
const ok = ask();
assert.equal(ok.version, 'v0.18.29');
assert.equal(ok.recorded_fingerprint, ok.current_fingerprint, '剛戳完版就該對得上');
assert.deepEqual(ok.changed, []);
writeFileSync(join(t.appDir, 'main.go'), 'package main\n// 沒重打包\n');
const bad = ask();
assert.notEqual(bad.recorded_fingerprint, bad.current_fingerprint);
assert.deepEqual(bad.changed, ['cmd/arcrun-app/main.go'], '要講得出是哪個檔');
assert.equal(readFileSync(join(t.appDir, '.version-source.json'), 'utf8'), before,
'--source-state 問了兩次,帳本一個位元都不准動');
} finally { t.cleanup(); }
});