diff --git a/.env.example b/.env.example index 2db6325b..74fb9072 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.3 +APP_VERSION=7.0.4 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 7a637226..94e4f911 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.3 +APP_VERSION=7.0.4 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc3db24..00fa2103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.4] - 2026-07-11 + +### Fixed + +- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.) +- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.) + ## [7.0.3] - 2026-07-10 ### Fixed diff --git a/INSTALL.md b/INSTALL.md index ad65f5a1..573b2221 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.3 g7 +# (필요 시) mv g7-7.0.4 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 0c1abe3e..906de834 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Extension/Vendor/EnvironmentDetector.php b/app/Extension/Vendor/EnvironmentDetector.php index 574d8abf..91c1402a 100644 --- a/app/Extension/Vendor/EnvironmentDetector.php +++ b/app/Extension/Vendor/EnvironmentDetector.php @@ -71,7 +71,7 @@ class EnvironmentDetector * → PATH 검색 → composer.phar * * @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색) - * @return string|null 발견된 composer 바이너리 경로. 없으면 null + * @return string|null 발견된 composer 바이너리 경로. 없으면 null */ public function findComposerBinary(?string $hint = null): ?string { @@ -120,7 +120,7 @@ class EnvironmentDetector * proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0. * * @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용) - * @return bool composer 가 현재 환경에서 실행 가능한지 여부 + * @return bool composer 가 현재 환경에서 실행 가능한지 여부 */ public function canExecuteComposer(?string $hint = null): bool { @@ -178,7 +178,7 @@ class EnvironmentDetector * 지정 경로에 쓰기 가능한지 확인. * * @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사) - * @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부 + * @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부 */ public function canWriteVendor(string $targetPath): bool { @@ -254,9 +254,15 @@ class EnvironmentDetector /** * composer 실행 명령 문자열 구성. * - * @param array $args + * 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다. + * Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat` + * 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다. + * + * @param string $binary composer 바이너리 경로 (findComposerBinary 반환값) + * @param array $args composer 하위 명령과 옵션 (예: ['install', '--no-dev']) + * @return string proc_open 에 넘길 실행 명령 문자열 */ - private function buildComposerCommand(string $binary, array $args): string + public function buildComposerCommand(string $binary, array $args): string { $escaped = array_map('escapeshellarg', $args); diff --git a/app/Extension/Vendor/VendorBundler.php b/app/Extension/Vendor/VendorBundler.php index 7084a4bc..8a3f5b6e 100644 --- a/app/Extension/Vendor/VendorBundler.php +++ b/app/Extension/Vendor/VendorBundler.php @@ -82,7 +82,7 @@ class VendorBundler * 코어는 소스 = 출력 경로가 base_path() 로 동일합니다. * * @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드 - * @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등) + * @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등) */ public function buildForCore(bool $force = false): VendorBundleResult { @@ -106,7 +106,7 @@ class VendorBundler * @param string $type 확장 타입 ('module' 또는 'plugin') * @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce') * @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드 - * @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true) + * @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true) */ public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult { @@ -164,7 +164,7 @@ class VendorBundler * * @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리) * @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스) - * @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false + * @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false */ public function isStale(string $sourcePath, ?string $outputPath = null): bool { @@ -187,16 +187,19 @@ class VendorBundler return true; } - if (file_exists($composerJsonPath)) { - $currentHash = $this->integrityChecker->computeFileHash($composerJsonPath); + // 해시 비교 대상은 manifest 가 놓인 출력 디렉토리 기준 ({@see build()} 참조). + // 소스만 검사하면 _bundled 의 composer.json 변경을 up-to-date 로 오보한다. + $hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json'); + if ($hashJsonPath !== null) { + $currentHash = $this->integrityChecker->computeFileHash($hashJsonPath); if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) { return true; } } - $composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock'; - if (file_exists($composerLockPath)) { - $currentHash = $this->integrityChecker->computeFileHash($composerLockPath); + $hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock'); + if ($hashLockPath !== null) { + $currentHash = $this->integrityChecker->computeFileHash($hashLockPath); if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) { return true; } @@ -205,6 +208,35 @@ class VendorBundler return false; } + /** + * manifest 해시 비교 기준이 될 파일 경로를 결정합니다. + * + * manifest 와 zip 은 출력 디렉토리(_bundled)에 함께 저장되어 그대로 배포되며, + * 런타임 검증({@see VendorIntegrityChecker::verify()})은 zip 이 놓인 디렉토리 + * (_bundled 또는 설치/업데이트 시 그 복사본인 _pending/staging)의 composer.json 을 + * 대조합니다. 따라서 해시도 그 디렉토리를 기준으로 계산해야 생성자와 검증자의 + * 기준이 일치합니다. + * + * 출력 디렉토리에 파일이 없으면 소스(활성 디렉토리)로 폴백합니다. + * 코어는 소스와 출력이 동일하므로 어느 쪽을 골라도 같은 경로가 됩니다. + * + * @param string $sourcePath composer.json/lock 과 vendor/ 를 읽는 경로 (활성 디렉토리) + * @param string $outputPath manifest 와 zip 을 쓰는 경로 (_bundled, 코어는 소스와 동일) + * @param string $filename 'composer.json' 또는 'composer.lock' + * @return string|null 해시 대상 파일의 절대 경로. 양쪽 모두 없으면 null + */ + private function resolveHashTarget(string $sourcePath, string $outputPath, string $filename): ?string + { + $outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename; + if (file_exists($outputFile)) { + return $outputFile; + } + + $sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename; + + return file_exists($sourceFile) ? $sourceFile : null; + } + /** * 빌드 실행. * @@ -258,20 +290,25 @@ class VendorBundler ); } - // 기존 파일 제거 - if (file_exists($zipPath)) { - @unlink($zipPath); - } - if (file_exists($manifestPath)) { - @unlink($manifestPath); - } - // === 스테이징 빌드 === // 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를 // 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다. + // + // 새 zip/manifest 는 임시 경로(.tmp)에 먼저 쓰고, 빌드가 성공한 뒤에만 원본을 + // 교체한다. 기존 파일을 빌드 전에 삭제하면 composer install 실패 시 새 파일이 + // 생성되지 못한 채 원본만 유실되어 이후 module:update 가 번들 부재로 실패한다. $stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true)); File::ensureDirectoryExists($stagingDir, 0755); + $tmpZipPath = $zipPath.'.tmp'; + $tmpManifestPath = $manifestPath.'.tmp'; + if (file_exists($tmpZipPath)) { + @unlink($tmpZipPath); + } + if (file_exists($tmpManifestPath)) { + @unlink($tmpManifestPath); + } + try { // 1. composer.json + composer.lock 을 스테이징으로 복사 File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json'); @@ -292,8 +329,8 @@ class VendorBundler ); } - // 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요) - [$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath); + // 4. zip 생성 — 임시 경로에 먼저 쓴다 (EXCLUDE_PATTERNS 만 적용) + [$zipSize, $fileCount] = $this->writeZip($stagedVendor, $tmpZipPath); // 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출 $stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock'; @@ -301,27 +338,35 @@ class VendorBundler ? $this->collectPackages($stagedLockPath) : []; + // manifest 의 해시는 **출력 디렉토리** 의 composer.json/lock 기준. + // manifest·zip·composer.json 이 함께 배포되는 곳이 출력 디렉토리이며, + // 런타임 검증도 그 디렉토리를 대조한다 ({@see resolveHashTarget()}). + $hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json'); + $hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock'); + $manifest = [ 'schema_version' => self::SCHEMA_VERSION, 'generated_at' => date('c'), 'generator' => 'g7 vendor-bundle:build', 'target' => $target, - // manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서 - // 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여 - // stale 여부를 감지한다. - 'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath), - 'composer_lock_sha256' => file_exists($sourceLockPath) - ? $this->integrityChecker->computeFileHash($sourceLockPath) + 'composer_json_sha256' => $this->integrityChecker->computeFileHash($hashJsonPath ?? $composerJsonPath), + 'composer_lock_sha256' => $hashLockPath !== null + ? $this->integrityChecker->computeFileHash($hashLockPath) : null, - 'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath), + 'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath), 'zip_size' => $zipSize, 'package_count' => count($packages), - 'php_requirement' => $this->extractPhpRequirement($composerJsonPath), + 'php_requirement' => $this->extractPhpRequirement($hashJsonPath ?? $composerJsonPath), 'g7_version' => config('app.version'), 'packages' => $packages, ]; - File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + File::put($tmpManifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // 6. 원자적 교체 — 여기까지 성공했으므로 임시 파일을 최종 경로로 옮긴다. + // 이 지점 이전에 실패하면 기존 번들이 그대로 보존된다. + $this->promoteAtomic($tmpZipPath, $zipPath); + $this->promoteAtomic($tmpManifestPath, $manifestPath); Log::info('Vendor 번들 빌드 완료', [ 'target' => $target, @@ -340,10 +385,42 @@ class VendorBundler reason: 'built', ); } finally { - // 스테이징 정리 (실패/성공 무관) + // 스테이징 및 미승격 임시 파일 정리 (실패/성공 무관) if (File::isDirectory($stagingDir)) { File::deleteDirectory($stagingDir); } + if (file_exists($tmpZipPath)) { + @unlink($tmpZipPath); + } + if (file_exists($tmpManifestPath)) { + @unlink($tmpManifestPath); + } + } + } + + /** + * 임시 파일을 최종 경로로 원자적으로 교체합니다. + * + * Windows 에서 `rename()` 은 대상이 이미 존재하면 실패하므로, 기존 파일을 먼저 + * 제거한 뒤 이동한다. 이 메서드는 빌드가 성공적으로 완료된 뒤에만 호출되므로, + * 여기서 기존 파일을 제거해도 (제거 후 이동 실패 확률이 극히 낮아) 안전하다. + * + * @param string $tmpPath 임시 파일 경로 (성공적으로 생성 완료된 상태) + * @param string $finalPath 최종 경로 + * + * @throws VendorInstallException 이동 실패 시 + */ + private function promoteAtomic(string $tmpPath, string $finalPath): void + { + if (file_exists($finalPath)) { + @unlink($finalPath); + } + + if (! @rename($tmpPath, $finalPath)) { + throw new VendorInstallException( + errorKey: 'bundle_build_promote_failed', + context: ['from' => $tmpPath, 'to' => $finalPath], + ); } } @@ -377,30 +454,37 @@ class VendorBundler ); } - $phpBinary = config('process.php_binary', 'php'); - - if (str_contains($binary, ' ')) { - $composerCmd = $binary; - } elseif (str_ends_with(strtolower($binary), '.phar')) { - $composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary); - } else { - $composerCmd = escapeshellarg($binary); - } - - $command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1'; + // 명령 조립과 실행 옵션은 canExecuteComposer 와 동일한 방식으로 통일한다. + // buildComposerCommand + bypass_shell:true 를 함께 써야 Windows 에서 .bat + // 경로가 cmd.exe 의 따옴표 처리로 깨지지 않는다. + $command = $detector->buildComposerCommand($binary, [ + 'install', '--no-dev', '--no-scripts', '--prefer-dist', '--no-interaction', '--no-progress', + ]); Log::info('vendor-bundle 빌드: composer install 시작', [ 'target' => $target, 'staging' => $stagingDir, ]); - $descriptors = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; + // stdout/stderr 는 파이프가 아닌 파일 descriptor 로 받는다. + // (Windows 회귀) composer 는 autoload dump 단계에서 임시 추출물을 + // `rmdir /S /Q` async `cmd.exe` 손자 프로세스로 정리하는데, stdout/stderr 를 + // 파이프로 열면 이 손자들이 부모 파이프 핸들을 상속받아 붙잡은 채 종료하지 않아 + // 부모(php)의 read/proc_close 가 자식 완료 신호를 영영 받지 못하고 무한 대기한다. + // 파일 descriptor 는 손자에게 상속될 열린 파이프 핸들이 없어 이 교착을 원천 차단한다 + // (실측: 파이프=행, 파일=140s 완주 EXIT 0 + autoload 정상 생성). + $outPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stdout.log'; + $errPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stderr.log'; + $descriptors = self::composerOutputDescriptors($outPath, $errPath); - $process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv()); + $process = @proc_open( + $command, + $descriptors, + $pipes, + $stagingDir, + EnvironmentDetector::buildComposerEnv(), + ['bypass_shell' => true], + ); if (! is_resource($process)) { throw new VendorInstallException( @@ -409,12 +493,17 @@ class VendorBundler ); } - fclose($pipes[0]); - $output = stream_get_contents($pipes[1]); - fclose($pipes[1]); - fclose($pipes[2]); + // stdin(파이프)만 즉시 닫고, stdout/stderr 는 파일이므로 배수 불필요 — + // proc_close 가 자식 종료까지 블록 대기한다. + if (isset($pipes[0]) && is_resource($pipes[0])) { + fclose($pipes[0]); + } $exitCode = proc_close($process); + $stdout = is_file($outPath) ? (string) @file_get_contents($outPath) : ''; + $stderr = is_file($errPath) ? (string) @file_get_contents($errPath) : ''; + $output = trim($stdout.\PHP_EOL.$stderr); + if ($exitCode !== 0) { throw new VendorInstallException( errorKey: 'bundle_build_composer_failed', @@ -428,13 +517,34 @@ class VendorBundler ]); } + /** + * composer install 의 stdout/stderr 를 받을 proc_open descriptor 배열을 구성합니다. + * + * stdout/stderr 를 파이프가 아닌 파일로 여는 이유는 runComposerInstall 주석 참조 — + * Windows 에서 composer autoload dump 의 async 손자 프로세스가 상속받는 파이프 핸들로 + * 인한 무한 대기(행)를 원천 차단한다. 이 메서드는 순수 함수로, descriptor 구조를 + * 회귀 테스트로 고정하기 위해 분리했다. + * + * @param string $outPath stdout 로그 파일 경로 + * @param string $errPath stderr 로그 파일 경로 + * @return array> proc_open 2번째 인자용 descriptor 배열 + */ + public static function composerOutputDescriptors(string $outPath, string $errPath): array + { + return [ + 0 => ['pipe', 'r'], + 1 => ['file', $outPath, 'w'], + 2 => ['file', $errPath, 'w'], + ]; + } + /** * vendor/ 디렉토리를 재귀적으로 zip에 쓰기. * * 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로 * 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다. * - * @return array{0: int, 1: int} [zipSize, fileCount] + * @return array{0: int, 1: int} [zipSize, fileCount] */ private function writeZip(string $vendorPath, string $zipPath): array { diff --git a/config/app.php b/config/app.php index 4885bc61..df842759 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.3'), + 'version' => env('APP_VERSION', '7.0.4'), /* |-------------------------------------------------------------------------- diff --git a/docs/extension/vendor-bundle.md b/docs/extension/vendor-bundle.md index 4a099b11..58a184fe 100644 --- a/docs/extension/vendor-bundle.md +++ b/docs/extension/vendor-bundle.md @@ -30,6 +30,28 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용 이 네 파일(`composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip`)은 코어 루트 및 각 `_bundled` 확장 루트에 함께 존재하며 모두 Git 으로 추적한다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상이다. `composer.lock` 이 누락되면 `VendorIntegrityChecker` 가 `composer_lock_sha256` 을 검증할 수 없으므로, `composer update` 직후 생성된 `composer.lock` 과 재빌드된 번들 두 파일을 같은 커밋에 함께 포함한다. +### 1.1 소스와 출력의 분리 + +확장의 번들 빌드는 읽는 곳과 쓰는 곳이 다르다. + +| 구분 | 경로 | 근거 | +|------|------|------| +| 소스 (`vendor/` 를 읽는 곳) | 활성 디렉토리 (`modules/{id}`) | `composer install` 이 실행되어 실제 패키지가 설치된 곳 | +| 출력 (`vendor-bundle.{json,zip}` 을 쓰는 곳) | `_bundled` 디렉토리 | Git 추적 배포 아티팩트 저장 위치 (`_bundled` 오염 방지) | + +반면 manifest 에 기록하는 `composer_json_sha256` / `composer_lock_sha256` 은 **출력 디렉토리**의 `composer.json` / `composer.lock` 을 기준으로 계산한다 (`VendorBundler::resolveHashTarget()`). 출력 디렉토리에 해당 파일이 없으면 소스로 폴백하며, 코어는 소스와 출력이 동일하므로 결과가 같다. + +이유는 검증자의 대조 위치에 있다. `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다. + +| 경로 | verify 에 넘기는 디렉토리 | +|------|--------------------------| +| 신규 설치 | `_pending/{id}` (`_bundled` 복사본) | +| 업데이트 | `_pending/{id}_updating_*` (`_bundled` 복사본) | +| 코어 업데이트 | pending 경로 (소스 = 출력) | +| `vendor-bundle:verify-all` | `_bundled/{id}` | + +해시를 소스(활성)에서 계산하면 생성 기준과 검증 기준이 어긋난다. 두 `composer.json` 의 내용이 같을 때만 우연히 통과하며, `_bundled` 의 `composer.json` 만 변경된 상태(버전 bump 직후 등)에서는 manifest 가 영구히 불일치하여 `module:update` 가 `composer_json_sha_mismatch` 로 차단된다. 그 오류를 해소할 유일한 명령이 `module:update` 이므로 순환이 발생한다. 같은 이유로 `isStale()` 도 출력 디렉토리를 검사해야 하며, 소스만 보면 `--check` 가 stale 을 `up-to-date` 로 오보한다. + `vendor-bundle.json` 스키마 (v1.0): ```json @@ -212,11 +234,11 @@ Content-Type: application/json ### 7.1 SHA256 검증 -`VendorIntegrityChecker::verify()` 가 다음을 검증합니다: +`VendorIntegrityChecker::verify($sourceDir)` 가 다음을 검증합니다. `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리이며, 확장의 경우 `_bundled` 또는 그 복사본(`_pending`)입니다 (§1.1). - vendor-bundle.zip 파일의 실제 SHA256 vs manifest 기록 값 -- composer.json SHA256 (소스에 존재 시) -- composer.lock SHA256 (소스에 존재 시) +- composer.json SHA256 (해당 디렉토리에 존재 시) +- composer.lock SHA256 (해당 디렉토리에 존재 시) 검증 실패 시 `VendorInstallException` 이 발생하며 설치가 중단됩니다. @@ -230,6 +252,9 @@ Content-Type: application/json ## 8. 자동 트리거 `composer.json` 또는 `composer.lock` 파일이 변경되면 번들이 stale 상태가 됩니다. +확장의 경우 판정 기준은 `_bundled` 디렉토리의 두 파일이다 (§1.1). 확장 버전을 올려 +`_bundled/composer.json` 의 `version` 만 바뀐 경우에도 stale 이 되므로 재빌드가 필요하다. + 다음 명령으로 재빌드해야 합니다: ```bash @@ -269,6 +294,8 @@ CI 통합 예시: | `composer_not_available` | composer 모드 강제했으나 실행 불가 | --vendor-mode=auto 또는 bundled로 변경 | | `no_vendor_strategy_available` | composer 불가 + 번들 없음 | vendor-bundle.zip 업로드 또는 호스팅 변경 | | `bundle_contains_unsafe_path` | 외부 zip 파일이 zip slip 시도 | 신뢰할 수 있는 소스에서만 다운로드 | +| `bundle_build_promote_failed` | 빌드 산출물을 최종 경로로 원자적 교체 실패 | 디스크 공간/권한 확인 후 재빌드 (원본 번들은 promoteAtomic 으로 보존됨) | +| 빌드가 `Generating optimized autoload files` 직후 무한 대기 (Windows) | composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유 | 코어 반영됨 — stdout/stderr 를 파이프가 아닌 파일 descriptor 로 실행 (`VendorBundler::composerOutputDescriptors`). 최신 코어에서는 재발하지 않음 | ## 11. 참고 diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index d59a08bd..ec217b20 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-07-11 + +### Added + +- vendor-bundle 빌드 결과 반영 실패 안내 메시지 일본어 번역 추가 (`exceptions.vendor.bundle_build_promote_failed`) — 빌드 산출물을 최종 경로로 원자적 교체하는 데 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.2] - 2026-07-08 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php index cf6b007f..f77cd321 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php @@ -60,6 +60,7 @@ return [ 'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path', 'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path', 'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。', + 'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)', ], 'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)', ]; diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index 0acc94e5..6f9a724c 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "scope": "core", "target_identifier": null, diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 00bebad6..b80766cb 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-07-10 + +### Added + +- 알림 설정의 "무통장 입금 안내"·"배송 완료" 알림 이름에 대한 일본어 번역을 추가했습니다. + ## [1.0.2] - 2026-07-10 ### Removed diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json index 29a74807..fc0635e2 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json @@ -458,7 +458,9 @@ "new_order_admin": "新規注文(管理者)", "inquiry_received": "商品問い合わせ受け取り(管理者)", "inquiry_replied": "商品問い合わせ回答", - "mileage_expiring_soon": "マイレージ失効予定" + "mileage_expiring_soon": "マイレージ失効予定", + "order_pending_deposit": "銀行振込のご案内", + "order_delivered": "配送完了" }, "btn_search": "検索", "btn_reset_filter": "初期化", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 814f617c..0cd77c21 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 4ffdea8f..7e2c0016 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -57,6 +57,7 @@ return [ 'composer_not_available' => 'Composer cannot be executed in this environment. Use bundled mode instead.', 'composer_not_available_for_build' => 'Building vendor-bundle requires Composer execution. Install/configure Composer in the development environment and retry.', 'bundle_build_composer_failed' => 'composer install failed while building vendor-bundle (exit :exit): :message', + 'bundle_build_promote_failed' => 'failed to move vendor-bundle build result to its final path (:from → :to)', 'composer_execution_failed' => 'Composer execution failed: :message', 'bundle_zip_missing' => 'vendor-bundle.zip not found: :path', 'bundle_manifest_missing' => 'vendor-bundle.json not found: :path', diff --git a/lang/ko/exceptions.php b/lang/ko/exceptions.php index e9703807..61bef32e 100644 --- a/lang/ko/exceptions.php +++ b/lang/ko/exceptions.php @@ -57,6 +57,7 @@ return [ 'composer_not_available' => 'Composer를 실행할 수 없는 환경입니다. 번들 모드를 사용하세요.', 'composer_not_available_for_build' => 'vendor-bundle 빌드는 Composer 실행이 필요합니다. 개발 환경에서 Composer를 설치/설정한 후 다시 시도하세요.', 'bundle_build_composer_failed' => 'vendor-bundle 빌드 중 composer install 실행 실패 (exit :exit): :message', + 'bundle_build_promote_failed' => 'vendor-bundle 빌드 결과를 최종 경로로 반영하지 못했습니다 (:from → :to)', 'composer_execution_failed' => 'Composer 실행에 실패했습니다: :message', 'bundle_zip_missing' => 'vendor-bundle.zip 파일을 찾을 수 없습니다: :path', 'bundle_manifest_missing' => 'vendor-bundle.json 파일을 찾을 수 없습니다: :path', diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 287f7454..777310d0 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-07-10 + +### Fixed + +- 쇼핑몰 환경설정 > 알림 설정에서 "무통장 입금 안내"와 "배송 완료" 두 알림의 이름이 사람이 읽을 수 없는 내부 문자열로 표시되던 문제를 수정했습니다. 이제 다른 알림과 동일하게 이름이 표시되며, 알림을 기본값으로 되돌릴 때 뜨는 확인창과 알림 편집 화면에서도 함께 바로잡혔습니다. + ## [1.0.2] - 2026-07-10 ### Changed diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index d7af07c0..cc0f1bb7 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 8463b86d..673f89fa 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index dc9901a7..33a0593a 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 08a25b1e..88de7fcf 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.2", + "version": "1.0.3", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationDefinitionTypeLabels.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationDefinitionTypeLabels.test.tsx new file mode 100644 index 00000000..e07d3e06 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationDefinitionTypeLabels.test.tsx @@ -0,0 +1,182 @@ +// e2e:allow 라벨 데이터(settings.json) + Vitest 렌더 회귀 테스트만 변경 — 레이아웃 동작/구조 무변경. 알림 정의 화면의 실제 렌더 경로($t: 동적 키 해석)는 본 render 테스트가 커버하고, 화면 수준 검증은 별도 브라우저 실측(handoff §10)으로 확인. +/** + * @file notificationDefinitionTypeLabels.test.tsx + * @description 이커머스 알림 정의 화면 — 타입 라벨 다국어 키 raw 노출 회귀 가드. + * + * 배경: `order_pending_deposit`, `order_delivered` 두 알림 타입이 module.php 에는 + * 정의됐으나 라벨을 읽는 settings.json 의 `notification_definitions.types` 에 이름이 + * 등록되지 않아, 알림 정의 화면에서 + * `sirsoft-ecommerce.admin.settings.notification_definitions.types.order_pending_deposit` + * 같은 raw 키가 그대로 노출됐다. + * + * 본 테스트는 _tab_notification_definitions.json 이 사용하는 실제 바인딩 + * `$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}` 을 + * 재현하고, **실제 settings.json** 라벨을 주입해 두 타입이 raw 키가 아닌 사람이 읽는 + * 라벨로 렌더되는지 검증한다. settings.json 에서 어느 한 키가 제거되면 이 테스트가 + * 다시 red 로 떨어져 회귀를 차단한다. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + createLayoutTest, + createMockComponentRegistryWithBasics, + screen, + type MockComponentRegistry, +} from '@core/template-engine/__tests__/utils/layoutTestUtils'; + +import settingsKo from '../../../lang/partial/ko/admin/settings.json'; +import settingsEn from '../../../lang/partial/en/admin/settings.json'; + +// 실제 화면(_tab_notification_definitions.json line ~371)의 타입 라벨 바인딩을 그대로 축약 재현. +// 알림 목록 항목마다 H4 에 `$t:...types.{{def.type}}` 로 이름을 표시한다. +const LABEL_BINDING = + '$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}'; + +function makeLayout() { + return { + name: 'notification_definition_type_labels_probe', + version: '1.0.0', + components: [ + { + type: 'basic', + name: 'Div', + children: [ + { + type: 'basic', + name: 'Div', + iteration: { + source: '{{ecommerceNotificationDefinitions?.data?.data ?? []}}', + item_var: 'def', + index_var: 'defIdx', + }, + children: [ + { + type: 'basic', + name: 'H4', + text: LABEL_BINDING, + }, + ], + }, + ], + }, + ], + }; +} + +// 실제 서빙 데이터를 모사한 알림 정의 목록 — 문제의 두 타입 포함. +const DEFINITIONS = [ + { id: 1, type: 'order_pending_deposit', templates: [], variables: [] }, + { id: 2, type: 'order_delivered', templates: [], variables: [] }, +]; + +// settings.json 은 `notification_definitions` 를 admin.settings 하위가 아닌 파일 루트에 둔다. +// 화면 바인딩은 `sirsoft-ecommerce.admin.settings.notification_definitions...` 네임스페이스를 +// 쓰므로, 주입 시 admin.settings 아래로 매핑한다(활성 언어팩 로더의 병합 결과와 동일 구조). +function buildTranslations(settings: Record) { + return { + 'sirsoft-ecommerce': { + admin: { + settings: settings, + }, + }, + }; +} + +let registry: MockComponentRegistry; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('알림 정의 타입 라벨 — 다국어 키 raw 노출 회귀 가드', () => { + it('ko: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => { + registry = createMockComponentRegistryWithBasics(); + const { render, cleanup } = createLayoutTest(makeLayout(), { + componentRegistry: registry as any, + locale: 'ko', + translations: buildTranslations(settingsKo), + initialData: { + ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } }, + }, + }); + + await render(); + + expect(screen.getByText('무통장 입금 안내')).toBeInTheDocument(); + expect(screen.getByText('배송 완료')).toBeInTheDocument(); + + // raw 키가 화면에 남아있으면 회귀 + expect( + screen.queryByText(/notification_definitions\.types\.order_pending_deposit/), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/notification_definitions\.types\.order_delivered/), + ).not.toBeInTheDocument(); + + cleanup(); + }); + + it('en: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => { + registry = createMockComponentRegistryWithBasics(); + const { render, cleanup } = createLayoutTest(makeLayout(), { + componentRegistry: registry as any, + locale: 'en', + translations: buildTranslations(settingsEn), + initialData: { + ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } }, + }, + }); + + await render(); + + expect(screen.getByText('Bank Transfer Payment Guide')).toBeInTheDocument(); + expect(screen.getByText('Order Delivered')).toBeInTheDocument(); + + expect( + screen.queryByText(/notification_definitions\.types\.order_pending_deposit/), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/notification_definitions\.types\.order_delivered/), + ).not.toBeInTheDocument(); + + cleanup(); + }); + + it('라벨 제거 시 raw 키가 노출됨 — 회귀 가드가 실제로 결함을 감지한다', async () => { + // 이 테스트는 위 두 가드가 "우연히 통과"하는 것이 아님을 증명한다: + // settings.json 에서 두 타입 라벨을 제거하면 화면에 raw 키가 그대로 노출된다. + registry = createMockComponentRegistryWithBasics(); + const stripped = JSON.parse(JSON.stringify(settingsKo)); + delete stripped.notification_definitions.types.order_pending_deposit; + delete stripped.notification_definitions.types.order_delivered; + + const { render, cleanup } = createLayoutTest(makeLayout(), { + componentRegistry: registry as any, + locale: 'ko', + translations: buildTranslations(stripped), + initialData: { + ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } }, + }, + }); + + await render(); + + expect(screen.queryByText('무통장 입금 안내')).not.toBeInTheDocument(); + expect( + screen.getByText(/notification_definitions\.types\.order_pending_deposit/), + ).toBeInTheDocument(); + + cleanup(); + }); + + it('settings.json(ko/en) 의 types 에 두 신규 타입 라벨이 존재한다', () => { + const koTypes = (settingsKo as any).notification_definitions.types; + const enTypes = (settingsEn as any).notification_definitions.types; + + expect(koTypes.order_pending_deposit).toBe('무통장 입금 안내'); + expect(koTypes.order_delivered).toBe('배송 완료'); + expect(enTypes.order_pending_deposit).toBe('Bank Transfer Payment Guide'); + expect(enTypes.order_delivered).toBe('Order Delivered'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json index 33d6470d..13a76973 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json @@ -447,7 +447,9 @@ "save_error": "Failed to save notification settings.", "types": { "order_confirmed": "Order Confirmed", + "order_pending_deposit": "Bank Transfer Payment Guide", "order_shipped": "Order Shipped", + "order_delivered": "Order Delivered", "order_completed": "Purchase Completed", "order_cancelled": "Order Cancelled", "new_order_admin": "New Order (Admin)", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json index 975c5e24..b6993c20 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json @@ -447,7 +447,9 @@ "save_error": "알림 설정 저장에 실패했습니다.", "types": { "order_confirmed": "주문 확인", + "order_pending_deposit": "무통장 입금 안내", "order_shipped": "배송 시작", + "order_delivered": "배송 완료", "order_completed": "구매 확정", "order_cancelled": "주문 취소", "new_order_admin": "신규 주문 (관리자)", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index 42c229a6..a0448d27 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -1,11 +1,11 @@ { "schema_version": "1.0", - "generated_at": "2026-07-10T12:05:27+00:00", + "generated_at": "2026-07-10T20:22:38+00:00", "generator": "g7 vendor-bundle:build", "target": "module:sirsoft-ecommerce", - "composer_json_sha256": "aa8f74063b7f9cd969cb4effb120e3c50bb944a4d3b2e16fae7c31bd8232fce8", + "composer_json_sha256": "5195d789bc9edd0b5140c42aeedd7c87be67c7252b477a879ff90607b4787935", "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d", - "zip_sha256": "ef77515e4d920a52a8adba1e6d7e310389c6f3f459586d3cd5c24749f24d7fb0", + "zip_sha256": "c1767e8eca61163667e9289a3963454bab92806ce4e8a3651777f1e1aea14081", "zip_size": 435549, "package_count": 1, "php_requirement": "^8.2", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index 0f7ac28f..a823e196 100644 Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ diff --git a/tests/Unit/Extension/Vendor/VendorBundlerTest.php b/tests/Unit/Extension/Vendor/VendorBundlerTest.php index 8bcb3416..6fcb0d5a 100644 --- a/tests/Unit/Extension/Vendor/VendorBundlerTest.php +++ b/tests/Unit/Extension/Vendor/VendorBundlerTest.php @@ -2,8 +2,10 @@ namespace Tests\Unit\Extension\Vendor; +use App\Extension\Vendor\EnvironmentDetector; use App\Extension\Vendor\Exceptions\VendorInstallException; use App\Extension\Vendor\VendorBundler; +use App\Extension\Vendor\VendorBundleResult; use App\Extension\Vendor\VendorIntegrityChecker; use Illuminate\Support\Facades\File; use Tests\TestCase; @@ -79,7 +81,7 @@ class VendorBundlerTest extends TestCase * * 테스트는 단일 testDir 을 소스/출력 동일하게 사용 (코어 케이스와 유사). */ - private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): \App\Extension\Vendor\VendorBundleResult + private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): VendorBundleResult { $reflection = new \ReflectionClass($this->bundler); $method = $reflection->getMethod('build'); @@ -239,6 +241,157 @@ class VendorBundlerTest extends TestCase $this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.json'); } + /** + * 분리 구조에서 manifest 의 composer 해시는 **출력(_bundled)** 의 composer.json 기준이어야 합니다. + * + * 런타임 검증(VendorIntegrityChecker::verify)은 manifest 와 같은 디렉토리(_bundled 또는 그 복사본인 + * staging)의 composer.json 을 대조한다. 해시를 활성 디렉토리에서 계산하면, _bundled 의 composer.json + * 만 변경된 상태(예: 버전 bump 직후)에서 manifest 가 영구히 불일치하여 module:update 가 막힌다. + */ + public function test_build_hashes_output_composer_json_not_source(): void + { + $sourceDir = $this->testDir.'/active'; + $outputDir = $this->testDir.'/bundled'; + File::ensureDirectoryExists($sourceDir); + File::ensureDirectoryExists($outputDir); + + // 활성 — 구버전 (아직 update 가 반영되지 않은 상태) + File::put($sourceDir.'/composer.json', json_encode([ + 'name' => 'test/project', + 'version' => '1.0.2', + 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'], + ])); + File::put($sourceDir.'/composer.lock', json_encode([ + 'packages' => [ + ['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'], + ], + ])); + + // _bundled — 신버전 (작업자가 version bump 한 상태) + File::put($outputDir.'/composer.json', json_encode([ + 'name' => 'test/project', + 'version' => '1.0.3', + 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'], + ])); + + $this->invokeBuild('test:split-hash', sourcePath: $sourceDir, outputPath: $outputDir); + + $manifest = json_decode(File::get($outputDir.'/vendor-bundle.json'), true); + + $this->assertSame( + $this->checker->computeFileHash($outputDir.'/composer.json'), + $manifest['composer_json_sha256'], + 'manifest 의 composer_json_sha256 은 출력(_bundled) 의 composer.json 해시여야 합니다.' + ); + + // 검증자가 outputDir 을 sourceDir 로 verify 할 때 통과해야 한다 (module:update 경로 재현) + $this->assertTrue( + $this->checker->verify($outputDir)->valid, + 'build 직후 _bundled 를 verify 하면 무결성 검증을 통과해야 합니다.' + ); + } + + /** + * 분리 구조에서 isStale 은 **출력(_bundled)** 의 composer.json 변경을 감지해야 합니다. + * + * 활성 디렉토리만 검사하면 _bundled 의 stale 을 up-to-date 로 오보하여, --check 가 재빌드 필요를 + * 놓치고 이후 module:update 가 무결성 오류로 실패한다. + */ + public function test_is_stale_detects_output_composer_json_change(): void + { + $sourceDir = $this->testDir.'/active'; + $outputDir = $this->testDir.'/bundled'; + File::ensureDirectoryExists($sourceDir); + File::ensureDirectoryExists($outputDir); + + $composerJson = json_encode([ + 'name' => 'test/project', + 'version' => '1.0.2', + 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'], + ]); + File::put($sourceDir.'/composer.json', $composerJson); + File::put($sourceDir.'/composer.lock', json_encode([ + 'packages' => [ + ['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'], + ], + ])); + File::put($outputDir.'/composer.json', $composerJson); + + $this->invokeBuild('test:split-stale', sourcePath: $sourceDir, outputPath: $outputDir); + $this->assertFalse( + $this->bundler->isStale($sourceDir, $outputDir), + '빌드 직후에는 stale 이 아니어야 합니다.' + ); + + // _bundled 의 composer.json 만 변경 (활성은 그대로) — 버전 bump 시나리오 + File::put($outputDir.'/composer.json', json_encode([ + 'name' => 'test/project', + 'version' => '1.0.3', + 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'], + ])); + + $this->assertTrue( + $this->bundler->isStale($sourceDir, $outputDir), + '_bundled 의 composer.json 이 변경되면 stale 로 판정해야 합니다.' + ); + } + + /** + * 재빌드가 실패하면 기존 번들 파일이 보존되어야 합니다. + * + * 기존 zip/manifest 를 staging 빌드 이전에 삭제하면, composer install 실패 시 + * 새 파일이 생성되지 못한 채 원본만 유실되어 다음 module:update 가 번들 부재로 + * 실패한다 (Windows 등에서 composer 실행이 불안정할 때 재현). 삭제는 재생성 + * 성공이 확정된 뒤에 이뤄져야 한다. + */ + public function test_failed_rebuild_preserves_existing_bundle(): void + { + $this->createFakeProject(); + + // 1차 빌드 성공 — 정상 번들 생성 + $this->invokeBuild('test:project'); + $this->assertFileExists($this->testDir.'/vendor-bundle.zip'); + $this->assertFileExists($this->testDir.'/vendor-bundle.json'); + + $originalZip = File::get($this->testDir.'/vendor-bundle.zip'); + $originalManifest = File::get($this->testDir.'/vendor-bundle.json'); + + // composer install 이 실패하도록 러너 교체 + $this->bundler->setComposerInstallRunner(function (): void { + throw new VendorInstallException( + errorKey: 'bundle_build_composer_failed', + context: ['exit' => 1, 'message' => 'simulated composer failure'], + ); + }); + + // 2차 빌드(force) — 실패해야 하지만 기존 번들은 보존되어야 함 + try { + $this->invokeBuild('test:project', force: true); + $this->fail('composer install 실패 시 빌드는 예외를 던져야 합니다.'); + } catch (VendorInstallException) { + // 예상된 실패 + } + + $this->assertFileExists( + $this->testDir.'/vendor-bundle.zip', + '재빌드 실패 시 기존 zip 이 보존되어야 합니다.' + ); + $this->assertFileExists( + $this->testDir.'/vendor-bundle.json', + '재빌드 실패 시 기존 manifest 가 보존되어야 합니다.' + ); + $this->assertSame( + $originalZip, + File::get($this->testDir.'/vendor-bundle.zip'), + '보존된 zip 은 원본과 동일해야 합니다.' + ); + $this->assertSame( + $originalManifest, + File::get($this->testDir.'/vendor-bundle.json'), + '보존된 manifest 는 원본과 동일해야 합니다.' + ); + } + /** * 외부 의존성 없는 확장은 isStale 이 항상 false 를 반환해야 합니다. */ @@ -306,7 +459,7 @@ class VendorBundlerTest extends TestCase */ public function test_build_throws_when_composer_not_available(): void { - $detector = \Mockery::mock(\App\Extension\Vendor\EnvironmentDetector::class); + $detector = \Mockery::mock(EnvironmentDetector::class); $detector->shouldReceive('canExecuteComposer')->andReturn(false); $bundler = new VendorBundler($this->checker, $detector); @@ -321,4 +474,33 @@ class VendorBundlerTest extends TestCase $method->setAccessible(true); $method->invoke($bundler, $this->testDir, $this->testDir, 'test:no-composer', false); } + + /** + * composer install 의 stdout/stderr 는 파이프가 아닌 파일 descriptor 로 열려야 한다. + * + * (Windows 회귀 가드) stdout/stderr 를 파이프로 열면 composer autoload dump 단계의 + * async `cmd.exe` 손자 프로세스가 부모 파이프 핸들을 상속·점유해 proc_close 가 + * 자식 완료 신호를 받지 못하고 무한 대기(행)한다. 실측: 파이프=행, 파일=140s 완주. + * descriptor 구조를 파이프로 되돌리면 이 테스트가 red 로 회귀를 차단한다. + */ + public function test_composer_output_descriptors_use_files_not_pipes(): void + { + $descriptors = VendorBundler::composerOutputDescriptors('/tmp/out.log', '/tmp/err.log'); + + // stdin 은 파이프(입력 없음 즉시 닫힘) 로 유지. + $this->assertSame(['pipe', 'r'], $descriptors[0]); + + // stdout/stderr 는 파일 descriptor 여야 한다 (파이프 금지 — 손자 핸들 상속 차단). + $this->assertSame('file', $descriptors[1][0], 'stdout 은 파일 descriptor 여야 함 (파이프 시 Windows 행 회귀)'); + $this->assertSame('/tmp/out.log', $descriptors[1][1]); + $this->assertSame('w', $descriptors[1][2]); + + $this->assertSame('file', $descriptors[2][0], 'stderr 은 파일 descriptor 여야 함 (파이프 시 Windows 행 회귀)'); + $this->assertSame('/tmp/err.log', $descriptors[2][1]); + $this->assertSame('w', $descriptors[2][2]); + + // 어떤 채널도 'pipe','w' 로 열리지 않아야 함 (손자 상속 가능한 열린 파이프 부재). + $this->assertNotSame('pipe', $descriptors[1][0]); + $this->assertNotSame('pipe', $descriptors[2][0]); + } } diff --git a/vendor-bundle.json b/vendor-bundle.json index 5bc88ef4..3e5c26e5 100644 --- a/vendor-bundle.json +++ b/vendor-bundle.json @@ -1,15 +1,15 @@ { "schema_version": "1.0", - "generated_at": "2026-06-30T04:00:54+00:00", + "generated_at": "2026-07-10T21:33:36+00:00", "generator": "g7 vendor-bundle:build", "target": "core", "composer_json_sha256": "76294945639dbbe4b6d054c0aea4b78377a55bcdffcd1c9136beb294bc7cfc90", "composer_lock_sha256": "b4d173c40088a78c9fe42b3ca6d23436bee6cc850a17bb653cbc1aca59ca3a81", - "zip_sha256": "0a11e8d918f58465d231bfe2ba84a0d9f749112ca4b79e19ac0224145abc0ef1", + "zip_sha256": "d5d21fdd930350332d2d58e5ab386ba9203795f72f7767eb3c214d4702152245", "zip_size": 14750349, "package_count": 104, "php_requirement": "^8.2", - "g7_version": "7.0.0", + "g7_version": "7.0.3", "packages": [ { "name": "aws/aws-crt-php", diff --git a/vendor-bundle.zip b/vendor-bundle.zip index 761e8d31..2bffbd05 100644 Binary files a/vendor-bundle.zip and b/vendor-bundle.zip differ