feat(self-hosted): acr update 內容指紋跳過未變動 worker(22 個沒變的不再白跑)+ --force 強制全部重部

壓測 2026-06-12:22/23 成功後重跑仍全部 pnpm install + wrangler deploy。
manifest 存 ~/.arcrun/deploy-manifest.json,指紋含注入後內容+accountId(換帳號/KV 自動重部),
只在成功後記錄(失敗者下次必重試)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-13 13:35:15 +08:00
parent 5a8d27673b
commit b44adda6d2
3 changed files with 78 additions and 6 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ import {
type DeployContext,
} from '../lib/deploy.js';
export async function cmdUpdate(): Promise<void> {
export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
const config = loadConfig();
if (config.mode !== 'self-hosted') {
@@ -81,7 +81,7 @@ export async function cmdUpdate(): Promise<void> {
d1DatabaseId: d1DatabaseId || undefined,
};
const result = await downloadAndDeploy(ctx);
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
if (result.implemented) {
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
+2 -1
View File
@@ -176,7 +176,8 @@ program
program
.command('update')
.description('self-hosted:拉新 release 並重新部署到你的 Cloudflare')
.action(() => cmdUpdate());
.option('--force', '強制重部所有 worker(忽略未變動跳過快取)')
.action((opts: { force?: boolean }) => cmdUpdate({ force: opts.force }));
// acr install-harness(把 arcrun 的 CC harness 裝進當前專案)
program
+74 -3
View File
@@ -10,10 +10,59 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-1222/23 成功後重跑仍全部
* pnpm install + wrangler deploy22 個沒變的白跑)。存 ~/.arcrun/。
* 指紋含 wrangler.toml 注入後的內容 → 換帳號/KV 會變更指紋 → 自動重部,不會誤跳。*/
const MANIFEST_PATH = join(homedir(), '.arcrun', 'deploy-manifest.json');
function loadManifest(): Record<string, string> {
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, string>;
} catch {
return {};
}
}
function saveManifest(m: Record<string, string>): void {
try {
writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2));
} catch {
/* manifest 寫失敗不致命:下次全部重部(退化成舊行為,不會錯,只是慢) */
}
}
/** 算一個 worker 目錄的內容指紋:遞迴 hash 所有檔案(排除 node_modules),
* 加上 accountId(換帳號要重部)。檔案路徑相對化後排序 → 跨機器/temp 目錄穩定。*/
function dirContentHash(dir: string, accountId: string): string {
const h = createHash('sha256');
h.update(accountId);
const walk = (d: string, rel: string): void => {
let entries: string[];
try { entries = readdirSync(d).sort(); } catch { return; }
for (const name of entries) {
if (name === 'node_modules' || name === '.git') continue;
const full = join(d, name);
const relPath = rel ? `${rel}/${name}` : name;
let st;
try { st = statSync(full); } catch { continue; }
if (st.isDirectory()) {
walk(full, relPath);
} else {
h.update(relPath);
try { h.update(readFileSync(full)); } catch { /* skip unreadable */ }
}
}
};
walk(dir, '');
return h.digest('hex');
}
/** GitHub repocodeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
@@ -82,7 +131,11 @@ export function wranglerAvailable(): boolean {
* @param ctx 部署上下文
* @param ref git refbranch / tag),預設 mainacr update 可帶 tag
*/
export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise<DeployResult> {
export async function downloadAndDeploy(
ctx: DeployContext,
ref = 'main',
opts: { force?: boolean } = {},
): Promise<DeployResult> {
// 1. 下載 + 解壓 codeload tarball
let root: string;
try {
@@ -106,7 +159,11 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi
const allDirs = [...tier1, ...tier2];
const failures: string[] = [];
let deployed = 0;
console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(每個含 install + deploy,依序進行)...`));
let skipped = 0;
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
// --force 清空 manifest → 全部重部。
const manifest = opts.force ? {} : loadManifest();
console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(未變動者跳過,依序進行)...`));
for (let i = 0; i < allDirs.length; i++) {
const dir = allDirs[i];
const tomlPath = join(dir, 'wrangler.toml');
@@ -114,14 +171,28 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx);
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
skipped++;
console.log(chalk.gray(' ⊘ 未變動,跳過'));
continue;
}
runWranglerDeploy(dir, ctx);
manifest[label] = hash; // 只在成功後記錄 → 失敗者下次必重試
saveManifest(manifest);
deployed++;
console.log(chalk.green(' ✓'));
} catch (e) {
delete manifest[label]; // 失敗 → 清掉舊指紋,確保下次重部
saveManifest(manifest);
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
console.log(chalk.yellow(' ⚠'));
}
}
if (skipped > 0) {
console.log(chalk.gray(` ${skipped} 個未變動已跳過;要強制全部重部跑 acr update --force`));
}
// 3.5 KBDB Base: D1 建好後套 migration(建三表 + recipe_stat seed)。
// 建 D1cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。