release-admin-web.mjs 4.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. #!/usr/bin/env node
  2. // Prepare and verify static releases without reading or uploading server credentials.
  3. import fs from 'node:fs/promises';
  4. import path from 'node:path';
  5. import { fileURLToPath } from 'node:url';
  6. import { createHash } from 'node:crypto';
  7. import { execFileSync } from 'node:child_process';
  8. const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  9. const sha=data=>createHash('sha256').update(data).digest('hex');
  10. const run=(cmd,args,options={})=>execFileSync(cmd,args,{cwd:repo,stdio:'inherit',...options});
  11. export async function manifestFor(directory,releaseId,commit){
  12. const files=[];
  13. async function walk(relative=''){
  14. for(const entry of await fs.readdir(path.join(directory,relative),{withFileTypes:true})){
  15. const name=path.posix.join(relative,entry.name);
  16. if(entry.isSymbolicLink()||/[\r\n]/.test(name)||/(^|\/)\.(env|git)|\.(pem|key)$/i.test(name))throw Error('发布包包含不允许的文件: '+name);
  17. if(entry.isDirectory())await walk(name);
  18. else if(entry.isFile()&&name!=='deployment-version.json')files.push({path:name,sha256:sha(await fs.readFile(path.join(directory,name)))});
  19. }
  20. }
  21. await walk();
  22. if(!files.some(f=>f.path==='index.html')||!files.some(f=>/^main-.*\.js$/.test(f.path)))throw Error('缺少管理端生产入口或主脚本');
  23. return{application:'xiaoshu-admin',releaseId,commit,builtAt:new Date().toISOString(),files:files.sort((a,b)=>a.path.localeCompare(b.path))};
  24. }
  25. async function response(url){const result=await fetch(url,{signal:AbortSignal.timeout(30000),headers:{'Cache-Control':'no-cache'}});if(!result.ok)throw Error('HTTP '+result.status+' '+url);return result;}
  26. export async function verifyRelease(base,manifest){
  27. const url=new URL(base);if(url.protocol!=='https:'&&!['localhost','127.0.0.1'].includes(url.hostname))throw Error('公网发布地址必须使用 HTTPS');
  28. const live=await(await response(new URL('/deployment-version.json?release='+manifest.releaseId,url))).json();
  29. if(live.releaseId!==manifest.releaseId||live.commit!==manifest.commit)throw Error('公网版本与本次发布不一致');
  30. for(let i=0;i<manifest.files.length;i+=6)await Promise.all(manifest.files.slice(i,i+6).map(async file=>{
  31. const remote=new URL('/'+file.path,url);remote.searchParams.set('release',manifest.releaseId);
  32. if(sha(Buffer.from(await(await response(remote)).arrayBuffer()))!==file.sha256)throw Error('公网文件校验失败: '+file.path);
  33. }));
  34. const expected=manifest.files.find(f=>f.path==='index.html').sha256;
  35. for(const route of ['/admin/login','/admin/payroll']){
  36. const page=await response(new URL(route+'?release='+manifest.releaseId,url));
  37. if(!page.headers.get('content-type')?.includes('text/html')||sha(Buffer.from(await page.arrayBuffer()))!==expected)throw Error('Angular 深层路由未返回新入口: '+route);
  38. }
  39. return{releaseId:manifest.releaseId,commit:manifest.commit,filesVerified:manifest.files.length,url:url.origin};
  40. }
  41. async function main(){
  42. const mode=process.argv[2]||'--prepare';
  43. if(!['--prepare','--verify'].includes(mode))throw Error('使用 --prepare 或 --verify;上传前需另行配置现有站点的部署入口');
  44. const base=process.env.XIAOSHU_ADMIN_URL||'https://admin.xiaoshu.pro';
  45. const output=path.join(repo,'dist/admin-release'),bundle=path.join(output,'site');
  46. if(mode==='--verify'){const manifest=JSON.parse(await fs.readFile(path.join(bundle,'deployment-version.json'),'utf8'));console.log(JSON.stringify(await verifyRelease(base,manifest)));return;}
  47. const commit=run('git',['rev-parse','HEAD'],{encoding:'utf8',stdio:'pipe'}).trim();
  48. if(run('git',['status','--porcelain','--untracked-files=no'],{encoding:'utf8',stdio:'pipe'}).trim())throw Error('请先提交已跟踪文件的修改,再构建可追踪的发布版本');
  49. run('npm',['run','build:admin']);
  50. await fs.rm(output,{recursive:true,force:true});await fs.mkdir(output,{recursive:true});
  51. await fs.cp(path.join(repo,'dist/xiaoshu-admin/browser'),bundle,{recursive:true});
  52. const releaseId=commit.slice(0,12)+'-'+new Date().toISOString().replace(/[-:.]/g,'');
  53. const manifest=await manifestFor(bundle,releaseId,commit);
  54. await fs.writeFile(path.join(bundle,'deployment-version.json'),JSON.stringify(manifest,null,2));
  55. const archive=path.join(output,'xiaoshu-admin-'+releaseId+'.tar.gz');
  56. run('tar',['-czf',archive,'-C',bundle,'.']);
  57. await fs.writeFile(path.join(output,'SHA256SUMS'),sha(await fs.readFile(archive))+' '+path.basename(archive)+'\n');
  58. console.log(JSON.stringify({prepared:true,releaseId,archive,files:manifest.files.length}));
  59. }
  60. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url))main().catch(error=>{console.error(error.message);process.exitCode=1;});