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 제외.
2026-10-28 15:07:35 +00:00
* 도입 소스 출처(0.2.0 검토 수정): build 는 IMPORTED_SOURCES.json 을 lib/provenance.checkProvenance 로 검사해 깨끗하지 않으면(workingTreeDirty=true·불완전·불일치)
* artifact 를 만들지 않고 종료 1 — fail-closed. manifest.json 의 checks.provenanceClean/provenanceProblems 와 imported[].upstream 에 결과를 남기고,
* register --approve 와 HAL approveRelease 는 provenanceClean 이 true 가 아닌 릴리스를 승인하지 않는다. (테스트용: ARIWEB_IMPORTED_SOURCES 로 manifest 경로를 바꿀 수 있다.)
2026-10-26 11:58:15 +00:00
*/
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' ;
2026-10-28 15:07:35 +00:00
import { checkProvenance } from '../lib/provenance.mjs' ;
2026-10-26 11:58:15 +00:00
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 '' ; } };
2026-10-28 15:07:35 +00:00
const IMPORTED_FILE = process . env . ARIWEB_IMPORTED_SOURCES || join ( ROOT , 'IMPORTED_SOURCES.json' );
2026-10-26 11:58:15 +00:00
if ( cmd === 'build' ) {
2026-10-28 15:07:35 +00:00
// 도입 소스 출처 검사 — 깨끗하지 않으면 아무것도 만들지 않는다(fail-closed). 테스트보다 먼저 보아 시간을 아낀다
const importedManifest = existsSync ( IMPORTED_FILE ) ? JSON . parse ( readFileSync ( IMPORTED_FILE , 'utf8' )) : null ;
const prov = checkProvenance ( importedManifest );
if ( ! prov . ok ) { console . error ( `도입 소스 출처(provenance)가 깨끗하지 않아 릴리스 artifact 를 만들지 않습니다 ( ${ IMPORTED_FILE } ):` ); for ( const p of prov . problems ) console . error ( ` - ${ p } ` ); process . exit ( 1 ); }
2026-10-26 11:58:15 +00:00
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' ]);
2026-10-28 15:07:35 +00:00
let checks = { testsPassed : null , dirty , branch , provenanceClean : prov . ok , provenanceProblems : prov . problems };
2026-10-26 11:58:15 +00:00
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 )));
2026-10-26 13:56:42 +00:00
// 디렉터리·파일 모드 정규화(실제 환경 결함 수정): 개발 PC 의 umask(077)가 artifact 에 0700 으로 들어가면 서버에서 다른 실행 계정(arihakdang 등)이 코드를 읽지 못한다.
const tarArgs = [ '-czf' , abs , '-C' , ROOT , '--mode=u=rwX,go=rX' , '--owner=0' , '--group=0' , '--numeric-owner' , '--exclude=.git' , '--exclude=node_modules' , '--exclude=*/data' , '--exclude=*/data/*' , '--exclude=modules/*/data' , '--exclude=dist' , '--exclude=*.sqlite' , '--exclude=*.log' , '--exclude=*.tmp' ];
2026-10-26 11:58:15 +00:00
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 };
}
2026-10-28 15:07:35 +00:00
const manifest = { version : VERSION , mainCommit : commit , branch , builtAt : new Date (). toISOString (), artifacts , checks , imported : importedManifest . sources . map (( s ) => ({ name : s . name , repo : s . repo , commit : s . commit , upstream : s . upstream ? { version : s . upstream . version ?? null , branch : s . upstream . branch ?? null , headCommit : s . upstream . headCommit ?? null , workingTreeDirty : s . upstream . workingTreeDirty } : null , legacy : ! s . upstream })) };
2026-10-26 11:58:15 +00:00
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' ));
2026-10-28 15:07:35 +00:00
// 0.2.0 검토 수정(2차): 승인은 HAL 과 같은 한 규칙(core.releaseApprovalProblem) — testsPassed === true(--skip-tests 빌드는 등록만, 승인 불가) · dirty === false · provenanceClean === true 가 모두 기록된 manifest 만. DB 를 열기 전에 멈춘다(fail-closed)
const core = await import ( '../hal/core.mjs' );
if ( opt ( 'approve' )) { const why = core . releaseApprovalProblem ( mf . checks ); if ( why ) { console . error ( ` ${ why }${ ( mf . checks ? . provenanceProblems || []). map (( p ) => `\n - ${ p } ` ). join ( '' ) } \n테스트를 건너뛰지 않고(--skip-tests 없이) main 에 병합된 깨끗한 커밋에서, 깨끗한 IMPORTED_SOURCES.json(원본 저장소에 커밋된 고정 커밋, workingTreeDirty=false)으로 다시 build 하세요` ); 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 { loadConfig } = await import ( '../hal/config.mjs' );
2026-10-26 11:58:15 +00:00
process . env . ARIWEB_HAL_DATA = halData ; const config = loadConfig (); const d = { store : openDb ( join ( halData , 'hal.sqlite' ), MIGRATIONS ), vault : openVault ( halData ), config , log : () => {} };
2026-10-28 15:07:35 +00:00
// 0.2.0 검토 수정 4차: artifact 설치는 HAL 과 같은 불변·fail-closed 규칙(core.installArtifacts) — 바이트 해시·크기 검증 → 승인된 버전은 같을 때만 멱등, 다르면 파일·DB 불변 거부 → 새 설치는 임시 파일 → rename
let r ; try {
const artifacts = core . installArtifacts ( d , { version : mf . version , mainCommit : mf . mainCommit , builtAt : mf . builtAt , checks : mf . checks , artifacts : Object . fromEntries ( Object . entries ( mf . artifacts ). map (([ m , a ]) => [ m , { file : basename ( a . file ), sha256 : a . sha256 , size : a . size , src : join ( dist , a . file ) }])) });
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' ) });
} catch ( e ) { console . error ( `릴리스 등록 거부 ( ${ e . code || 'error' } ): ${ e . message } ` ); d . store . close (); process . exit ( 1 ); }
console . log ( `HAL 릴리스 등록: ${ r . version } ( ${ r . status } ) main ${ r . main_commit . slice ( 0 , 7 ) } — artifact ${ Object . keys ( r . artifacts ). join ( ', ' ) } ` ); d . store . close ();
2026-10-26 11:58:15 +00:00
} else { console . error ( `알 수 없는 명령: ${ cmd } ` ); process . exit ( 1 ); }