/** * scripts/release.mjs — 릴리스 artifact 만들기·검사·등록(§32.1). * * node scripts/release.mjs build [--skip-tests] [--out dist] 현재 git 작업 트리에서 dist//-.tar.gz + manifest.json * node scripts/release.mjs register --hal-data [--approve] [--dist dist] 로컬 HAL DB 에 등록(부트스트랩·CI) * node scripts/release.mjs verify --dist dist/ 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 제외. * 도입 소스 출처(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 경로를 바꿀 수 있다.) */ 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'; import { checkProvenance } from '../lib/provenance.mjs'; 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; 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'] }; 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 ''; } }; const IMPORTED_FILE = process.env.ARIWEB_IMPORTED_SOURCES || join(ROOT, 'IMPORTED_SOURCES.json'); if (cmd === 'build') { // 도입 소스 출처 검사 — 깨끗하지 않으면 아무것도 만들지 않는다(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); } 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, provenanceClean: prov.ok, provenanceProblems: prov.problems }; 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))); // 디렉터리·파일 모드 정규화(실제 환경 결함 수정): 개발 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']; 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: 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 })) }; 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')); // 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'); process.env.ARIWEB_HAL_DATA = halData; const config = loadConfig(); const d = { store: openDb(join(halData, 'hal.sqlite'), MIGRATIONS), vault: openVault(halData), config, log: () => {} }; // 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(); } else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }