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