2026-10-26 11:58:15 +00:00
/**
* scripts/release.mjs — 릴리스 artifact 만들기·검사·등록(§32.1).
*
* node scripts/release.mjs build [--skip-tests] [--out dist] 현재 git 작업 트리에서 dist/<version>/<module>-<version>.tar.gz + manifest.json
* node scripts/release.mjs register --hal-data <HAL 데이터 디렉터리> [--approve] [--dist dist] 로컬 HAL DB 에 등록(부트스트랩·CI)
* node scripts/release.mjs verify --dist dist/<version> manifest 의 sha256 과 파일 비교
* 버전은 package.json, main 커밋은 git rev-parse HEAD(작업 트리가 깨끗하지 않으면 dirty 표시 — 승인 불가 검사 기록).
* 모듈별 포함 경로: hal(hal, lib, deploy, lang, package.json) portal(portal, lib, lang, package.json) agent(agent, lib, deploy, package.json)
* webmaker(modules/webmaker 전체 - data) arimail(modules/arimail 전체 - data). .git·node_modules·data·dist·test 제외.
*/
import { execFileSync , spawnSync } from 'node:child_process' ;
import { readFileSync , writeFileSync , mkdirSync , existsSync , statSync , rmSync , readdirSync } from 'node:fs' ;
import { createHash } from 'node:crypto' ;
import { join , dirname , basename , resolve } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
const ROOT = join ( dirname ( fileURLToPath ( import . meta . url )), '..' );
const args = process . argv . slice ( 2 ); const cmd = args [ 0 ] || 'build' ; const opt = ( n , d = null ) => { const i = args . indexOf ( '--' + n ); return i >= 0 ? ( args [ i + 1 ] && ! args [ i + 1 ]. startsWith ( '--' ) ? args [ i + 1 ] : true ) : d ; };
const pkg = JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )); const VERSION = pkg . version ;
2026-10-26 13:45:42 +00:00
const MODULES = { hal : [ 'hal' , 'lib' , 'deploy' , 'lang' , 'package.json' , 'README.md' ], portal : [ 'portal' , 'lib' , 'lang' , 'catalog' , 'package.json' ], agent : [ 'agent' , 'lib' , 'deploy' , 'package.json' ], webmaker : [ 'modules/webmaker' ], arimail : [ 'modules/arimail' ] };
2026-10-26 11:58:15 +00:00
const sha = ( f ) => createHash ( 'sha256' ). update ( readFileSync ( f )). digest ( 'hex' );
const git = ( a ) => { try { return execFileSync ( 'git' , a , { cwd : ROOT , encoding : 'utf8' }). trim (); } catch ( _ ) { return '' ; } };
if ( cmd === 'build' ) {
const out = join ( resolve ( ROOT , opt ( 'out' , 'dist' )), VERSION ); rmSync ( out , { recursive : true , force : true }); mkdirSync ( out , { recursive : true });
const commit = git ([ 'rev-parse' , 'HEAD' ]); const dirty = git ([ 'status' , '--porcelain' ]). length > 0 ; const branch = git ([ 'rev-parse' , '--abbrev-ref' , 'HEAD' ]);
let checks = { testsPassed : null , dirty , branch };
if ( ! opt ( 'skip-tests' )) { const r = spawnSync ( process . execPath , [ 'test/run.mjs' ], { cwd : ROOT , stdio : 'inherit' }); checks . testsPassed = r . status === 0 ; if ( r . status !== 0 ) { console . error ( '테스트 실패 — artifact 를 만들지 않습니다' ); process . exit ( 1 ); } }
const artifacts = {};
for ( const [ m , paths ] of Object . entries ( MODULES )) {
const file = ` ${ m } - ${ VERSION } .tar.gz` ; const abs = join ( out , file ); const present = paths . filter (( p ) => existsSync ( join ( ROOT , p )));
const tarArgs = [ '-czf' , abs , '-C' , ROOT , '--exclude=.git' , '--exclude=node_modules' , '--exclude=*/data' , '--exclude=*/data/*' , '--exclude=modules/*/data' , '--exclude=dist' , '--exclude=*.sqlite' , '--exclude=*.log' , '--exclude=*.tmp' ];
if ( m === 'webmaker' || m === 'arimail' ) { tarArgs . push ( '--transform' , `s,^modules/ ${ m } ,.,` ); } tarArgs . push (... present );
execFileSync ( 'tar' , tarArgs ); artifacts [ m ] = { file , sha256 : sha ( abs ), size : statSync ( abs ). size , paths : present };
}
const manifest = { version : VERSION , mainCommit : commit , branch , builtAt : new Date (). toISOString (), artifacts , checks , imported : existsSync ( join ( ROOT , 'IMPORTED_SOURCES.json' )) ? JSON . parse ( readFileSync ( join ( ROOT , 'IMPORTED_SOURCES.json' ), 'utf8' )). sources . map (( s ) => ({ name : s . name , repo : s . repo , commit : s . commit })) : [] };
writeFileSync ( join ( out , 'manifest.json' ), JSON . stringify ( manifest , null , 1 ));
console . log ( `릴리스 ${ VERSION } ( ${ commit . slice ( 0 , 7 ) }${ dirty ? ', dirty' : '' } ) → ${ out } ` ); for ( const [ m , a ] of Object . entries ( artifacts )) console . log ( ` ${ m . padEnd ( 9 ) } ${ a . file } ${ ( a . size / 1024 ). toFixed ( 0 ) } KB sha256 ${ a . sha256 . slice ( 0 , 16 ) } …` );
} else if ( cmd === 'verify' ) {
const dist = resolve ( ROOT , opt ( 'dist' , join ( 'dist' , VERSION ))); const mf = JSON . parse ( readFileSync ( join ( dist , 'manifest.json' ), 'utf8' )); let bad = 0 ;
for ( const [ m , a ] of Object . entries ( mf . artifacts )) { const ok = existsSync ( join ( dist , a . file )) && sha ( join ( dist , a . file )) === a . sha256 ; console . log ( ` ${ ok ? '✔' : '✘' } ${ m } ${ a . file } ` ); if ( ! ok ) bad ++ ; }
process . exit ( bad ? 1 : 0 );
} else if ( cmd === 'register' ) {
const halData = opt ( 'hal-data' ); if ( ! halData ) { console . error ( '--hal-data 가 필요합니다' ); process . exit ( 1 ); } const dist = resolve ( ROOT , opt ( 'dist' , join ( 'dist' , VERSION ))); const mf = JSON . parse ( readFileSync ( join ( dist , 'manifest.json' ), 'utf8' ));
if ( mf . checks ? . dirty && opt ( 'approve' )) { console . error ( '작업 트리가 깨끗하지 않은(dirty) 빌드는 승인할 수 없습니다 — main 에 병합된 커밋에서 빌드하세요' ); process . exit ( 1 ); }
const { openDb } = await import ( '../lib/db.mjs' ); const { openVault } = await import ( '../lib/vault.mjs' ); const { MIGRATIONS } = await import ( '../hal/schema.mjs' ); const core = await import ( '../hal/core.mjs' ); const { loadConfig } = await import ( '../hal/config.mjs' );
process . env . ARIWEB_HAL_DATA = halData ; const config = loadConfig (); const d = { store : openDb ( join ( halData , 'hal.sqlite' ), MIGRATIONS ), vault : openVault ( halData ), config , log : () => {} };
const dir = join ( core . artifactDir ( d ), mf . version ); mkdirSync ( dir , { recursive : true });
const artifacts = {}; for ( const [ m , a ] of Object . entries ( mf . artifacts )) { const src = join ( dist , a . file ); if ( sha ( src ) !== a . sha256 ) { console . error ( ` ${ m } artifact 해시 불일치` ); process . exit ( 1 ); } const { copyFileSync } = await import ( 'node:fs' ); copyFileSync ( src , join ( dir , basename ( a . file ))); artifacts [ m ] = { file : basename ( a . file ), sha256 : a . sha256 , size : a . size }; }
const r = core . registerRelease ( d , { version : mf . version , mainCommit : mf . mainCommit , builtAt : mf . builtAt , artifacts , checks : mf . checks , approve : !! opt ( 'approve' ), by : opt ( 'by' , 'release-script' ) });
console . log ( `HAL 릴리스 등록: ${ r . version } ( ${ r . status } ) main ${ r . main_commit . slice ( 0 , 7 ) } — artifact ${ Object . keys ( artifacts ). join ( ', ' ) } ` ); d . store . close ();
} else { console . error ( `알 수 없는 명령: ${ cmd } ` ); process . exit ( 1 ); }