Files
douba-app-web/scripts/deploy.mjs
douba 72648b4914 初始化兜爸心选 App 官网项目(构建与发布)
- src/:从线上 shop.douba.top/public/app 拉取的页面源码(v0.3.8)
- 版本号、更新日期、安装包大小改为模板变量,统一由 site.config.json 管理,
  安装包大小在构建时从 APK 实际字节数计算
- scripts/build.mjs:构建到 dist/,校验 APK、模板变量与资源引用
- scripts/deploy.mjs:与线上比对差异 → 上传 → 备份 → 原子切换 → 线上验证
- scripts/rollback.mjs、scripts/preview.mjs:回滚与本地预览
- AGENTS.md:项目说明、发版流程与服务器/nginx 约定

由 AI 助手协助完成。
2026-10-29 06:20:35 +00:00

137 lines
6.0 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.
// 发布:构建 → 对比线上差异 → 上传 → 备份线上目录 → 切换 → 线上验证
// 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);
});