// 发布:构建 → 对比线上差异 → 上传 → 备份线上目录 → 切换 → 线上验证 // node scripts/deploy.mjs 正式发布 // node scripts/deploy.mjs --dry-run 只构建并列出差异,不改服务器 import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { build } from './build.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DIST = path.join(ROOT, 'dist'); const dryRun = process.argv.includes('--dry-run'); function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 << 20, ...opts }); if (r.status !== 0) { throw new Error(`${cmd} ${args.slice(0, 2).join(' ')} 失败(退出码 ${r.status})\n${r.stderr || ''}`); } return r.stdout; } const ssh = (host, script) => run('ssh', ['-o', 'BatchMode=yes', host, 'bash -s'], { input: `set -euo pipefail\n${script}` }); const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = path.join(dir, e.name); return e.isDirectory() ? walk(p) : [p]; }); } // 北京时间时间戳,用作备份目录名 function stamp() { const d = new Date(Date.now() + 8 * 3600e3).toISOString(); return d.slice(0, 19).replace(/[-:]/g, '').replace('T', '-'); } async function main() { const { cfg, apkFile, apkBytes, vars } = build(); const { host, siteDir, publicUrl, keepBackups } = cfg.deploy; const live = `${siteDir}/public/app`; const deployDir = `${siteDir}/.deploy`; // 在 public/ 之外,不会被公网访问 const ts = stamp(); console.log(`构建完成:v${cfg.version}(${cfg.channel}) · ${apkFile} · ${vars.apkSize}`); // ---- 对比线上(APK 单独比较,download/ 里的其他旧包不参与) ---- const local = new Map( walk(DIST) .filter((f) => !f.startsWith(path.join(DIST, 'download') + path.sep)) .map((f) => [path.relative(DIST, f), sha256(f)]), ); const remoteOut = ssh(host, ` cd ${live} find . -type f ! -path './download/*' -exec sha256sum {} + | sed 's| \\./| |' echo "__APK__ $(sha256sum download/${apkFile} 2>/dev/null | cut -d' ' -f1)" `); const remote = new Map(); let remoteApkSha = ''; for (const line of remoteOut.trim().split('\n')) { if (line.startsWith('__APK__')) remoteApkSha = line.slice(8).trim(); else if (line) { const [sum, file] = line.split(/\s{2}/); remote.set(file, sum); } } const added = [...local.keys()].filter((f) => !remote.has(f)); const changed = [...local.keys()].filter((f) => remote.has(f) && remote.get(f) !== local.get(f)); const removed = [...remote.keys()].filter((f) => !local.has(f)); const localApkSha = sha256(path.join(DIST, 'download', apkFile)); const uploadApk = remoteApkSha !== localApkSha; console.log('\n与线上的差异:'); for (const f of added) console.log(` + ${f}`); for (const f of changed) console.log(` ~ ${f}`); for (const f of removed) console.log(` - ${f}`); console.log(` APK ${apkFile}:${uploadApk ? (remoteApkSha ? '线上不同,需上传' : '线上没有,需上传') : '线上已是同一文件,跳过上传'}`); if (!added.length && !changed.length && !removed.length && !uploadApk) { console.log('\n线上已经是最新,无需发布。'); return; } if (dryRun) { console.log('\n--dry-run:未改动服务器。'); return; } // ---- 上传 ---- const tgz = path.join(os.tmpdir(), `douba-app-${ts}.tgz`); run('tar', ['czf', tgz, '-C', DIST, '--exclude=./download', '.']); const remoteTgz = `/tmp/douba-app-${ts}.tgz`; const remoteApk = `/tmp/douba-app-${ts}.apk`; console.log('\n上传站点文件…'); run('scp', ['-q', '-o', 'BatchMode=yes', tgz, `${host}:${remoteTgz}`]); fs.rmSync(tgz); if (uploadApk) { console.log(`上传 ${apkFile}(${vars.apkSize},需要一会儿)…`); run('scp', ['-q', '-o', 'BatchMode=yes', path.join(DIST, 'download', apkFile), `${host}:${remoteApk}`], { stdio: ['ignore', 'inherit', 'pipe'] }); } // ---- 服务器端:组装新目录 → 备份旧目录 → 切换 ---- // 新目录与线上同在一个分区,两次 mv 都是瞬间完成的重命名 console.log('切换线上目录…'); ssh(host, ` stage=${deployDir}/stage-${ts} mkdir -p "$stage/download" ${deployDir}/backups tar xzf ${remoteTgz} -C "$stage" # 保留线上已有的 APK(硬链接,不占额外空间),旧版下载链接不会失效 for f in ${live}/download/*.apk; do [ -e "$f" ] && ln "$f" "$stage/download/"; done ${uploadApk ? `mv -f ${remoteApk} "$stage/download/${apkFile}"` : ':'} [ "$(sha256sum "$stage/download/${apkFile}" | cut -d' ' -f1)" = "${localApkSha}" ] || { echo "APK 校验失败" >&2; exit 1; } chown -R root:root "$stage" find "$stage" -type d -exec chmod 755 {} + find "$stage" -type f -exec chmod 644 {} + mv ${live} ${deployDir}/backups/app-${ts} mv "$stage" ${live} rm -f ${remoteTgz} ls -1d ${deployDir}/backups/app-* | sort -r | tail -n +${keepBackups + 1} | xargs -r rm -rf `); // ---- 线上验证 ---- console.log('验证线上…'); const page = run('curl', ['-fsS', '-H', 'Cache-Control: no-cache', publicUrl]); if (!page.includes(`"softwareVersion": "${cfg.version}"`)) throw new Error(`${publicUrl} 返回的页面不是 v${cfg.version}`); const head = run('curl', ['-fsSI', `${publicUrl}/download/${apkFile}`]); const len = Number((/content-length:\s*(\d+)/i.exec(head) || [])[1]); if (len !== apkBytes) throw new Error(`APK 下载地址返回的大小 ${len} 与本地 ${apkBytes} 不一致`); console.log(`\n发布成功:${publicUrl} (v${cfg.version},备份 ${deployDir}/backups/app-${ts})`); console.log('如需回滚:npm run rollback'); } main().catch((e) => { console.error(`\n发布失败:${e.message}`); process.exit(1); });