59 lines
6.2 KiB
JavaScript
59 lines
6.2 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import {createReadStream} from 'node:fs';
|
|
import path from 'node:path';
|
|
import {createHash} from 'node:crypto';
|
|
import {run,OpsError,requireSuccess,npmInvocation} from './core.mjs';
|
|
import {gitState,git} from './git.mjs';
|
|
async function hashFile(file) {const hash=createHash('sha256');for await (const chunk of createReadStream(file))hash.update(chunk);return hash.digest('hex');}
|
|
export async function snapshot(directory, filter=()=>true) {
|
|
const results={};
|
|
async function visit(dir) {
|
|
for(const entry of await fs.readdir(dir,{withFileTypes:true}).catch(error=>{if(error.code==='ENOENT')return [];throw error;})) {
|
|
const file=path.join(dir,entry.name);
|
|
if(entry.isSymbolicLink())continue;
|
|
if(entry.isDirectory())await visit(file);
|
|
else if(entry.isFile()&&filter(file)) {const stat=await fs.stat(file);const hash=await hashFile(file);results[path.relative(directory,file)]={size:stat.size,mtimeMs:stat.mtimeMs,sha256:hash};}
|
|
}
|
|
}
|
|
await visit(directory);return results;
|
|
}
|
|
export function freshArtifacts(before, after, startedAt) {
|
|
return Object.entries(after).filter(([name,value])=>value.size>0 && value.mtimeMs>=startedAt-2000 && (!before[name] || before[name].sha256!==value.sha256 || before[name].mtimeMs!==value.mtimeMs)).map(([name,value])=>({name,...value}));
|
|
}
|
|
export async function sourceFingerprint(ctx) {
|
|
const listing=await git(ctx,['ls-files','--cached','--others','--exclude-standard','-z'],{maxOutput:16*1024*1024});
|
|
requireSuccess(listing,'SOURCE_SCAN_FAILED','Cannot enumerate source files.');
|
|
if(listing.outputTruncated)throw new OpsError('SOURCE_SCAN_TOO_LARGE','Source listing exceeds the evidence limit.');
|
|
const digest=createHash('sha256');
|
|
for(const name of [...new Set(listing.stdout.split('\0').filter(Boolean))].sort()) {
|
|
digest.update(name+'\0');
|
|
const file=path.join(ctx.root,name);
|
|
try{const stat=await fs.lstat(file);if(stat.isSymbolicLink())digest.update(await fs.readlink(file));else if(stat.isFile())digest.update(await hashFile(file));else digest.update('[non-file]');}catch(error){if(error.code==='ENOENT')digest.update('[deleted]');else throw error;}
|
|
}
|
|
return digest.digest('hex');
|
|
}
|
|
export async function build(ctx, action) {
|
|
if(action==='package' && (process.platform!=='win32'||process.arch!=='x64'))throw new OpsError('WINDOWS_X64_REQUIRED','Installer packaging is supported only on Windows x64.',{},'UNSUPPORTED');
|
|
const expected=action==='package'?'npm run runtimes:prepare && npm run build && electron-builder --win nsis --x64':'npm run typecheck && electron-vite build';
|
|
const script=action==='package'?'dist:installer':'build';
|
|
if(ctx.manifest.scripts?.[script]!==expected)throw new OpsError('SCRIPT_CONTRACT_CHANGED',`${script} script changed; review its safety before using this fixed operation.`);
|
|
if(ctx.manifest.scripts?.build!=='npm run typecheck && electron-vite build' || ctx.manifest.scripts?.typecheck!=='tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json' || (action==='package'&&ctx.manifest.scripts?.['runtimes:prepare']!=='node scripts/prepare-runtime-bundle.mjs'))throw new OpsError('SCRIPT_CONTRACT_CHANGED','Nested typecheck/runtime preparation script changed; review before execution.');
|
|
const directory=path.join(ctx.root,action==='package'?'release':'out');
|
|
const filter=action==='package'?file=>path.basename(file)===`YCode-${ctx.manifest.version}-Setup-x64.exe`:file=>/[/\\](?:main[/\\]index\.mjs|preload[/\\]index\.(?:js|mjs|cjs)|renderer[/\\]index\.html)$/.test(file);
|
|
for(const hook of ['prebuild','postbuild','predist:installer','postdist:installer','pretypecheck','posttypecheck','preruntimes:prepare','postruntimes:prepare'])if(ctx.manifest.scripts?.[hook])throw new OpsError('LIFECYCLE_HOOK_BLOCKED',`Unexpected ${hook} lifecycle hook needs review.`);
|
|
const before=await snapshot(directory,filter), initial=await gitState(ctx), fingerprint=await sourceFingerprint(ctx), startedAt=Date.now();
|
|
const npm=await npmInvocation();
|
|
const env={...process.env,CSC_IDENTITY_AUTO_DISCOVERY:'false'};
|
|
// This action creates local unsigned artifacts only; it never inherits signing credentials.
|
|
for(const key of Object.keys(env))if(/^(?:CSC_|WIN_CSC_|APPLE_|GH_TOKEN$|GITHUB_TOKEN$|AZURE_|YCODE_.*SIGN)/i.test(key)&&key.toUpperCase()!=='CSC_IDENTITY_AUTO_DISCOVERY')delete env[key];
|
|
const result=await run(npm.command,[...npm.prefix,'run',script,...(action==='package'?['--','--publish','never']:[])],{cwd:ctx.root,timeoutMs:ctx.timeoutMs,logDir:ctx.logDir,env});
|
|
if(result.timedOut)ctx.keepLock=true; // Descendant toolchain processes may remain; never allow an automatic overlapping retry.
|
|
requireSuccess(result,'BUILD_FAILED',`${action} failed; no source changes or dependency upgrades were attempted.`);
|
|
const after=await snapshot(directory,filter), artifacts=freshArtifacts(before,after,startedAt), current=await gitState(ctx);
|
|
const verified=action==='package'?artifacts.length>0:['main/index.mjs','renderer/index.html'].every(name=>artifacts.some(a=>a.name.split(path.sep).join('/')===name)) && artifacts.some(a=>/^preload\/index\.(?:js|mjs|cjs)$/.test(a.name.split(path.sep).join('/')));
|
|
if(!verified)throw new OpsError('ARTIFACTS_UNVERIFIED','Command exited successfully but expected fresh artifacts are missing.',{logPath:result.logPath,freshArtifacts:artifacts},'FAIL');
|
|
if(await sourceFingerprint(ctx)!==fingerprint)throw new OpsError('SOURCE_CHANGED','Source content changed during the operation; artifacts are not certified.',{before:initial,current,logPath:result.logPath},'FAIL');
|
|
if(current.sha!==initial.sha)throw new OpsError('VERSION_CHANGED','HEAD changed during the operation; artifact version cannot be certified.',{before:initial,current},'FAIL');
|
|
return {status:'PASS',code:action==='package'?'PACKAGED':'BUILT',summary:action==='package'?'Fresh unsigned Windows x64 installer created locally; not published.':'Build completed with fresh main, preload and renderer entry artifacts.',git:current,evidence:{startedAt:new Date(startedAt).toISOString(),logPath:result.logPath,artifactDirectory:directory,artifacts:artifacts.slice(0,10),sourceFingerprint:fingerprint,sourceDirty:initial.dirty,sourceIdentity:(initial.dirty||current.dirty)?'HEAD plus local modifications; not a pristine commit build':'HEAD'}};
|
|
}
|