Files
kefu/deploy/mobile-remote-20260928/prepare_bundle.py
T
2026-09-29 15:52:07 +08:00

99 lines
4.9 KiB
Python

"""Assemble the server bundle locally: payload + manifest + tar.gz. No upload, no deployment.
python prepare_bundle.py
Sources come from the working trees under C:/kefu (backend in wechat_rpa, the phone
console build in admin-web/apps/mobile-h5/dist). baseline-snapshot/ holds the exact bytes
production runs; every change production carries on top of git HEAD must already be in
the payload, so a lagging local copy cannot roll production back.
"""
from pathlib import Path
import hashlib, json, shutil, subprocess, tarfile, tempfile
folder = Path(__file__).resolve().parent
kefu = folder.parent.parent
plan = json.loads((folder / 'release-plan.json').read_text(encoding='utf-8'))
base = json.loads((folder / 'server-baseline.json').read_text(encoding='utf-8'))
sha = lambda data: hashlib.sha256(data).hexdigest()
lf = lambda data: data.replace(b'\r\n', b'\n')
# 1. Production is not git HEAD (the 2026-09-23 cos-direct release shipped admin_api.py from
# another working tree), so merge HEAD -> production into the payload: it must be a no-op.
for name, wanted in base['expected'].items():
snapshot = (folder / 'baseline-snapshot' / name).read_bytes()
assert sha(snapshot) == wanted, 'baseline-snapshot differs from server-baseline.json: ' + name
ours = lf((kefu / name).read_bytes())
committed = lf(subprocess.check_output(['git', '-C', str(kefu), 'show', f'HEAD:{name}']))
with tempfile.TemporaryDirectory() as tmp:
paths = []
for label, data in (('ours', ours), ('head', committed), ('production', lf(snapshot))):
(Path(tmp) / label).write_bytes(data)
paths.append(str(Path(tmp) / label))
merged = subprocess.run(['git', 'merge-file', '-p', *paths], capture_output=True)
assert merged.returncode == 0 and merged.stdout == ours, 'Payload is missing production changes: ' + name
# The phone console is served as-is; make sure the build did not pick up CRLF from Windows.
for text in (kefu / plan['frontendDir']).rglob('*'):
if text.is_file() and text.suffix in {'.html', '.js', '.css', '.json', '.webmanifest'}:
assert b'\r\n' not in text.read_bytes(), 'CRLF in build output: ' + str(text)
# 2. Fresh payload from the working trees.
payload = folder / 'payload'
if payload.exists():
shutil.rmtree(payload)
for name in plan['backendFiles'] + plan['newBackendFiles'] + plan['deployFiles']:
# Windows 工作区是 autocrlf 检出的 CRLF;仓库和生产服务器上都是 LF。
data = (kefu / name).read_bytes().replace(b'\r\n', b'\n')
assert b'\r' not in data, 'Stray carriage return: ' + name
if name.endswith('.py'):
compile(data, name, 'exec')
target = payload / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
dist = kefu / plan['frontendDir']
assert (dist / 'index.html').is_file(), 'Build the phone console first: cd admin-web/apps/mobile-h5 && pnpm build'
shutil.copytree(dist, payload / plan['frontendDir'])
tests = folder / 'tests'
if tests.exists():
shutil.rmtree(tests)
tests.mkdir()
for module in plan['serverTestModules']:
data = (kefu / 'wechat_rpa' / f'{module}.py').read_bytes().replace(b'\r\n', b'\n')
compile(data, module, 'exec')
(tests / f'{module}.py').write_bytes(data)
scripts = ['release-plan.json', 'server-baseline.json', 'Dockerfile.overlay', 'remote_server_guard.py',
'01-preflight.sh', '02-backup-build.sh', '03-deploy.sh', '04-verify.sh', 'rollback.sh']
# 3. Manifest over everything the server will verify.
manifest = []
for path in sorted(p for p in list(payload.rglob('*')) + list(tests.rglob('*')) if p.is_file()):
data = path.read_bytes()
manifest.append({'path': path.relative_to(folder).as_posix(), 'sha256': sha(data), 'bytes': len(data)})
(folder / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
# 4. Server bundle (LF line endings for shell scripts) and archive.
bundle = folder / 'server-bundle'
if bundle.exists():
shutil.rmtree(bundle)
bundle.mkdir()
shutil.copytree(payload, bundle / 'payload')
shutil.copytree(tests, bundle / 'tests')
for name in scripts + ['manifest.json']:
(bundle / name).write_bytes((folder / name).read_bytes().replace(b'\r\n', b'\n'))
archive = folder / 'mobile-remote-server-bundle.tar.gz'
if archive.exists():
archive.unlink()
with tarfile.open(archive, 'x:gz') as tf:
for p in sorted(bundle.rglob('*')):
if p.is_file():
arc = p.relative_to(bundle).as_posix(); assert not arc.startswith(('/', '..'))
info = tf.gettarinfo(str(p), arcname=arc)
info.mode = 0o755 if arc.endswith('.sh') else 0o644
info.uid = info.gid = 0
info.uname = info.gname = ''
with p.open('rb') as handle:
tf.addfile(info, handle)
print(json.dumps({'archive': str(archive), 'bytes': archive.stat().st_size, 'sha256': sha(archive.read_bytes()),
'files': len(manifest), 'uploaded': False}, ensure_ascii=False, indent=1))