From 25c0ab46ecaaf99e14fa973f58fcf5379101d6d6 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 11 Jul 2026 19:50:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(vendor-bundle):=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=97=90=20=EC=83=88=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=8B=9C=20=EB=B2=88=EB=93=A4=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌드가 composer install 의 입력(스테이징에 복사할 composer.json/lock)만 활성 디렉토리에서 가져오고 manifest 해시는 _bundled 기준으로 기록해, 두 판단이 서로 다른 파일을 보고 있었다. 개발자는 규정상 _bundled 에서만 작업하므로 새 패키지를 추가하면 _bundled 에만 반영되고 활성은 다음 update 전까지 구버전으로 남는다. 그 결과 구버전 lock 으로 설치한 zip 에 신버전 해시가 붙어, 무결성 검증은 통과하는데 새 패키지가 번들에서 누락되고 설치 후 런타임에 클래스 not found 로 터졌다. 설치 입력·해시 기준·의존성 판정·stale 판정 네 가지를 모두 resolveHashTarget (출력 우선, 없으면 소스 폴백) 으로 통일. 코어는 source == output 이라 무영향. 실증: 이커머스에 psr/log 추가 → 빌드 2 packages(수정 전 1) → zip 에 vendor/psr/log 포함 → module:update → 활성 vendor 배포 + Psr\Log\LoggerInterface 런타임 로드 확인. --- CHANGELOG.md | 1 + app/Extension/Vendor/VendorBundler.php | 44 +++++---- docs/extension/vendor-bundle.md | 24 +++-- .../Extension/Vendor/VendorBundlerTest.php | 96 +++++++++++++++++++ 4 files changed, 139 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fa2103..a4d8eca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.) - Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.) +- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.) ## [7.0.3] - 2026-07-10 diff --git a/app/Extension/Vendor/VendorBundler.php b/app/Extension/Vendor/VendorBundler.php index 8a3f5b6e..d590457c 100644 --- a/app/Extension/Vendor/VendorBundler.php +++ b/app/Extension/Vendor/VendorBundler.php @@ -170,8 +170,11 @@ class VendorBundler { $outputPath ??= $sourcePath; - $composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json'; - if (file_exists($composerJsonPath) && ! $this->hasExternalDependencies($composerJsonPath)) { + // 의존성 판정도 build() 와 같은 파일을 본다 (출력 우선, 없으면 소스 폴백). + // 소스만 보면 _bundled 에 외부 패키지를 처음 추가한 상태를 "의존성 없음" 으로 + // 오판해 재빌드가 필요 없다고 답한다. + $composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json'); + if ($composerJsonPath !== null && ! $this->hasExternalDependencies($composerJsonPath)) { return false; } @@ -247,12 +250,20 @@ class VendorBundler */ private function build(string $sourcePath, string $outputPath, string $target, bool $force): VendorBundleResult { - // 전제 조건 검증 - $composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json'; - if (! file_exists($composerJsonPath)) { + // 빌드가 사용하는 composer.json/lock 은 **출력(_bundled) 우선, 없으면 소스 폴백** 이다 + // ({@see resolveHashTarget()}). 설치 입력·해시 기준·런타임 검증이 모두 같은 파일을 + // 보아야 한다. + // + // 개발자는 규정상 `_bundled` 에서만 작업하므로 새 패키지를 추가하면 `_bundled` 의 + // composer.json/lock 에만 반영되고 활성 디렉토리는 구버전으로 남는다. 설치 입력만 + // 활성에서 가져오면 구버전 lock 으로 install 한 zip 에 신버전 해시가 붙어, 무결성은 + // 통과하는데 새 패키지가 번들에서 누락된다 (런타임에 클래스 not found). + // 코어는 source == output 이므로 항상 같은 파일이다. + $composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json'); + if ($composerJsonPath === null) { throw new VendorInstallException( errorKey: 'composer_json_not_found', - context: ['path' => $composerJsonPath], + context: ['path' => $sourcePath.DIRECTORY_SEPARATOR.'composer.json'], ); } @@ -309,12 +320,14 @@ class VendorBundler @unlink($tmpManifestPath); } + // 설치 입력 = 해시 기준과 동일한 파일 (출력 우선, 없으면 소스 폴백). + $composerLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock'); + try { // 1. composer.json + composer.lock 을 스테이징으로 복사 File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json'); - $sourceLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock'; - if (file_exists($sourceLockPath)) { - File::copy($sourceLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock'); + if ($composerLockPath !== null) { + File::copy($composerLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock'); } // 2. 스테이징에서 composer install --no-dev 실행 @@ -338,25 +351,22 @@ class VendorBundler ? $this->collectPackages($stagedLockPath) : []; - // manifest 의 해시는 **출력 디렉토리** 의 composer.json/lock 기준. + // 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, - 'composer_json_sha256' => $this->integrityChecker->computeFileHash($hashJsonPath ?? $composerJsonPath), - 'composer_lock_sha256' => $hashLockPath !== null - ? $this->integrityChecker->computeFileHash($hashLockPath) + 'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath), + 'composer_lock_sha256' => $composerLockPath !== null + ? $this->integrityChecker->computeFileHash($composerLockPath) : null, 'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath), 'zip_size' => $zipSize, 'package_count' => count($packages), - 'php_requirement' => $this->extractPhpRequirement($hashJsonPath ?? $composerJsonPath), + 'php_requirement' => $this->extractPhpRequirement($composerJsonPath), 'g7_version' => config('app.version'), 'packages' => $packages, ]; diff --git a/docs/extension/vendor-bundle.md b/docs/extension/vendor-bundle.md index 58a184fe..f9846194 100644 --- a/docs/extension/vendor-bundle.md +++ b/docs/extension/vendor-bundle.md @@ -30,18 +30,22 @@ 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 소스와 출력의 분리 +### 1.1 빌드가 보는 composer.json / composer.lock -확장의 번들 빌드는 읽는 곳과 쓰는 곳이 다르다. +확장의 번들 빌드는 산출물을 `_bundled` 에 쓰지만(활성 디렉토리 오염 방지), **입력으로 삼는 `composer.json` / `composer.lock` 도 `_bundled` 를 기준으로 한다**. 출력 디렉토리에 해당 파일이 없을 때만 활성 디렉토리로 폴백하며, 코어는 소스와 출력이 같은 경로(`base_path()`)이므로 언제나 동일한 파일을 본다. 이 규칙은 `VendorBundler::resolveHashTarget()` 한 곳에 있고, 빌드의 네 가지 판단이 모두 그것을 쓴다. -| 구분 | 경로 | 근거 | -|------|------|------| -| 소스 (`vendor/` 를 읽는 곳) | 활성 디렉토리 (`modules/{id}`) | `composer install` 이 실행되어 실제 패키지가 설치된 곳 | -| 출력 (`vendor-bundle.{json,zip}` 을 쓰는 곳) | `_bundled` 디렉토리 | Git 추적 배포 아티팩트 저장 위치 (`_bundled` 오염 방지) | +| 판단 | 사용처 | +|------|------| +| 외부 의존성 유무 (번들링 대상인가) | `build()`, `isStale()` | +| `composer install` 입력 (스테이징에 복사할 파일) | `build()` | +| manifest 해시 (`composer_{json,lock}_sha256`) | `build()` | +| stale 판정 (재빌드 필요한가) | `isStale()` | -반면 manifest 에 기록하는 `composer_json_sha256` / `composer_lock_sha256` 은 **출력 디렉토리**의 `composer.json` / `composer.lock` 을 기준으로 계산한다 (`VendorBundler::resolveHashTarget()`). 출력 디렉토리에 해당 파일이 없으면 소스로 폴백하며, 코어는 소스와 출력이 동일하므로 결과가 같다. +빌드는 활성 디렉토리의 `vendor/` 를 읽지 않는다. 스테이징 디렉토리에서 `composer install --no-dev` 를 새로 실행해 dev 의존성이 섞이지 않은 `vendor/` 를 만들고 그것을 압축한다. -이유는 검증자의 대조 위치에 있다. `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다. +네 판단이 같은 파일을 보아야 하는 이유는 두 가지다. + +**첫째, 검증자의 대조 위치.** `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다. | 경로 | verify 에 넘기는 디렉토리 | |------|--------------------------| @@ -50,7 +54,9 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용 | 코어 업데이트 | 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` 로 오보한다. +해시를 활성에서 계산하면 생성 기준과 검증 기준이 어긋난다. 두 `composer.json` 의 내용이 같을 때만 우연히 통과하며, `_bundled` 의 `composer.json` 만 변경된 상태(버전 bump 직후 등)에서는 manifest 가 영구히 불일치하여 `module:update` 가 `composer_json_sha_mismatch` 로 차단된다. 그 오류를 해소할 유일한 명령이 `module:update` 이므로 순환이 발생한다. `isStale()` 이 활성만 보면 `--check` 가 stale 을 `up-to-date` 로 오보하는 것도 같은 뿌리다. + +**둘째, 개발자가 `_bundled` 에서만 작업한다는 규정.** 새 패키지를 추가하면 `_bundled` 의 `composer.json` / `composer.lock` 에만 반영되고 활성 디렉토리는 다음 `{module|plugin}:update` 전까지 구버전으로 남는다. 이때 `composer install` 의 입력만 활성에서 가져오면, **구버전 lock 으로 설치한 zip 에 신버전 해시를 붙인 manifest** 가 만들어진다. 해시는 정합하므로 무결성 검증은 통과하는데 정작 새 패키지가 번들에서 빠져, 설치 후 런타임에 클래스 not found 로 터진다. 설치 입력과 해시 기준이 같은 파일을 보면 이 어긋남 자체가 성립하지 않는다. `vendor-bundle.json` 스키마 (v1.0): diff --git a/tests/Unit/Extension/Vendor/VendorBundlerTest.php b/tests/Unit/Extension/Vendor/VendorBundlerTest.php index 6fcb0d5a..ad6b14bc 100644 --- a/tests/Unit/Extension/Vendor/VendorBundlerTest.php +++ b/tests/Unit/Extension/Vendor/VendorBundlerTest.php @@ -291,6 +291,102 @@ class VendorBundlerTest extends TestCase ); } + /** + * 분리 구조에서 composer install 의 입력(staging 에 복사되는 composer.json/lock)도 + * **출력(_bundled)** 기준이어야 합니다. + * + * 개발자는 규정상 `_bundled` 에서만 작업하므로, 새 패키지를 추가하면 `_bundled/composer.json` + * 과 `_bundled/composer.lock` 에만 반영되고 활성 디렉토리는 구버전으로 남는다. 이때 설치 소스를 + * 활성에서 가져오면 **구버전 lock 으로 install 한 zip** 에 **신버전 해시**를 붙인 manifest 가 + * 만들어져, 무결성 검증은 통과하는데 정작 새 패키지가 번들에 빠지는 상태가 된다 + * (런타임에 클래스 not found). 설치 입력과 해시 기준이 같은 디렉토리를 보아야 한다. + */ + public function test_build_installs_from_output_composer_lock_not_source(): void + { + $sourceDir = $this->testDir.'/active'; + $outputDir = $this->testDir.'/bundled'; + File::ensureDirectoryExists($sourceDir); + File::ensureDirectoryExists($outputDir); + + // 활성 — 구버전: 패키지 1개 (아직 update 미반영) + File::put($sourceDir.'/composer.json', json_encode([ + 'name' => 'test/project', + '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 — 신버전: 개발자가 새 패키지(test/newpkg)를 추가한 상태 + File::put($outputDir.'/composer.json', json_encode([ + 'name' => 'test/project', + 'require' => ['php' => '^8.2', 'test/lib' => '^1.0', 'test/newpkg' => '^2.0'], + ])); + File::put($outputDir.'/composer.lock', json_encode([ + 'packages' => [ + ['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'], + ['name' => 'test/newpkg', 'version' => '2.0.0', 'type' => 'library'], + ], + ])); + + $this->invokeBuild('test:new-package', sourcePath: $sourceDir, outputPath: $outputDir); + + $manifest = json_decode(File::get($outputDir.'/vendor-bundle.json'), true); + $names = array_column($manifest['packages'], 'name'); + + // manifest 의 packages 는 staging 에 복사된 lock 에서 추출되므로, + // 이 단언이 곧 "어느 lock 으로 install 했는가" 를 판정한다. + $this->assertContains( + 'test/newpkg', + $names, + '_bundled 에 추가한 새 패키지가 번들에 포함되어야 합니다 (설치 소스가 활성이면 누락됨).' + ); + $this->assertSame( + 2, + $manifest['package_count'], + 'package_count 는 _bundled/composer.lock 기준(2)이어야 합니다.' + ); + + // 해시 기준과 설치 입력이 같은 디렉토리를 보므로 무결성도 정합해야 한다. + $this->assertTrue($this->checker->verify($outputDir)->valid); + } + + /** + * 출력 디렉토리에 composer.lock 이 없으면 소스(활성) 의 lock 으로 폴백해야 합니다. + * + * 코어는 source == output 이므로 항상 같은 파일이고, 확장도 _bundled 에 lock 이 없는 + * 과도기 상태에서 빌드가 깨지지 않아야 한다 (resolveHashTarget 과 동일한 폴백 규칙). + */ + public function test_build_falls_back_to_source_lock_when_output_lock_missing(): void + { + $sourceDir = $this->testDir.'/active'; + $outputDir = $this->testDir.'/bundled'; + File::ensureDirectoryExists($sourceDir); + File::ensureDirectoryExists($outputDir); + + File::put($sourceDir.'/composer.json', json_encode([ + 'name' => 'test/project', + '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 에 composer.json 만 있고 lock 은 없음 + File::put($outputDir.'/composer.json', File::get($sourceDir.'/composer.json')); + + $this->invokeBuild('test:lock-fallback', sourcePath: $sourceDir, outputPath: $outputDir); + + $manifest = json_decode(File::get($outputDir.'/vendor-bundle.json'), true); + $names = array_column($manifest['packages'], 'name'); + + $this->assertContains('test/lib', $names, '출력에 lock 이 없으면 소스 lock 으로 폴백해야 합니다.'); + } + /** * 분리 구조에서 isStale 은 **출력(_bundled)** 의 composer.json 변경을 감지해야 합니다. *