import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import {execFileSync} from 'node:child_process'; import {parse as parseToml} from 'smol-toml'; import {parseDocument} from 'yaml'; import {build,validateConfig} from './build.mjs'; import {verifyRelease,absolute} from './runtime.mjs'; export const validVersion=value=>typeof value==='string'&&/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/.test(value); export function release({root,version,configFile='platform/config.yaml',buildRelease=build,log=console.log}){ root=fs.realpathSync(root); const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim(); if(!validVersion(version))throw new Error('Release version must be SemVer, for example 0.0.6'); if(fs.realpathSync(git(['rev-parse','--show-toplevel']))!==root)throw new Error('Release requires the repository root'); if(git(['status','--porcelain','--untracked-files=all']))throw new Error('Release requires a clean Git worktree, including untracked files'); if(git(['rev-parse','--is-shallow-repository'])==='true')throw new Error('Release requires complete local tag history; use a full clone'); git(['symbolic-ref','--quiet','HEAD']); git(['var','GIT_AUTHOR_IDENT']);git(['var','GIT_COMMITTER_IDENT']); const configuration=absolute(path.resolve(root,configFile),'release config'); if(!configuration.startsWith(root+path.sep))throw new Error('Config must be inside repository'); const doc=parseDocument(fs.readFileSync(configuration,'utf8')); if(doc.errors.length||doc.warnings.length)throw new Error('Invalid release config'); const config=validateConfig(doc.toJS()); const inside=value=>{const file=absolute(path.resolve(root,value),'release path');if(!file.startsWith(root+path.sep))throw new Error('Release path must be inside repository');return file;}; const packFile=inside(path.join(config.pack.directory,'pack.toml')),output=inside(config.build.output_directory); const relative=path.relative(root,packFile),original=fs.readFileSync(packFile,'utf8'),pack=parseToml(original); if(!git(['ls-files','--',relative]))throw new Error('pack.toml must be tracked'); if(!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack.name))throw new Error('Unsafe pack name'); if(version===pack.version)throw new Error('Choose a new release version'); const tag='v'+version,destination=path.join(output,pack.name+'-'+version); if(fs.existsSync(destination)||git(['tag','--list',tag]))throw new Error('Release version or tag already exists'); const matches=[...original.matchAll(/^version\s*=\s*(["']).*?\1[^\r\n]*$/gm)]; if(matches.length!==1||parseToml(original.replace(matches[0][0],`version = "${version}"`)).version!==version)throw new Error('Expected one top-level version assignment in pack.toml'); const start=git(['rev-parse','HEAD']); const lock=path.join(root,'.tools/release.lock');fs.mkdirSync(path.dirname(lock),{recursive:true}); try{fs.mkdirSync(lock);}catch{throw new Error('Release is locked; inspect .tools/release.lock');} const candidate=path.join(output,'.release-check-'+crypto.randomUUID()); const updated=original.replace(matches[0][0],`version = "${version}"`); let committed=false,staged=false,artifact; try{ if(git(['rev-parse','HEAD'])!==start||git(['status','--porcelain','--untracked-files=all']))throw new Error('Git state changed before preparation'); fs.writeFileSync(packFile,updated); log('Validating candidate build before committing version.'); buildRelease({root,configFile,outputDirectory:candidate,log}); if(git(['rev-parse','HEAD'])!==start||git(['diff','--cached','--name-only'])||git(['status','--porcelain','--untracked-files=all','--','.',`:(exclude)${relative}`]))throw new Error('Git state changed during release preparation'); git(['add','--',relative]);staged=true; git(['commit','-m',`Release ${version}`]);committed=true; log('Building final release from version commit.'); artifact=buildRelease({root,configFile,releaseOnly:true,log}); const verified=verifyRelease(artifact),commit=git(['rev-parse','HEAD']); if(verified.manifest.git.dirty!==false||verified.manifest.git.commit!==commit||verified.manifest.pack.version!==version||path.resolve(artifact)!==destination||git(['status','--porcelain','--untracked-files=all']))throw new Error('Final release provenance differs from clean version commit'); git(['tag','-a',tag,'-m',`Release ${version}`,commit]); return {directory:artifact,version,tag,commit}; }catch(error){ const headChanged=git(['rev-parse','HEAD'])!==start; const canRestore=!committed&&!headChanged&&fs.readFileSync(packFile,'utf8')===updated; if(canRestore){ fs.writeFileSync(packFile,original);if(staged)git(['reset','--quiet','HEAD','--',relative]); } throw new Error(`${error.message}. ${committed?'Version commit retained; inspect artifact and Git state before recovery. No tag was created by the failed operation.':canRestore?'Version restored; no release commit or tag created.':'Concurrent changes retained; inspect Git and source before recovery.'}`); }finally{fs.rmSync(candidate,{recursive:true,force:true});fs.rmdirSync(lock);} }