first cycle lab release

This commit is contained in:
2026-10-04 00:30:09 +07:00
parent 85f6897365
commit d134daafd8
92 changed files with 5623 additions and 9 deletions
+63
View File
@@ -0,0 +1,63 @@
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);}
}