03a2c3d5f8
出貨線從第 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>
182 lines
10 KiB
JavaScript
182 lines
10 KiB
JavaScript
/**
|
||
* 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.28(2026-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.47(2026-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#88,2026-08-18)
|
||
//
|
||
// 上面那些案例刻意避開指紋機制(假樹不是 git repo ⇒ 指紋回空字串)。
|
||
// 下面三支相反:**一定要是 git repo**,因為要驗的就是指紋帳本本身。
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
/** 造一個像真的一樣的 collector 樹:是 git repo,dist/ 被 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(); }
|
||
});
|