first cycle lab release
This commit is contained in:
@@ -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);}
|
||||
}
|
||||
Reference in New Issue
Block a user