fix(vendor-bundle): 확장에 새 패키지 추가 시 번들 누락 수정

빌드가 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 런타임 로드 확인.
This commit is contained in:
HeuJung
2026-07-11 19:50:06 +09:00
parent 13e65131f4
commit 25c0ab46ec
4 changed files with 139 additions and 26 deletions
+1
View File
@@ -10,6 +10,7 @@
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.)
## [7.0.3] - 2026-07-10
+27 -17
View File
@@ -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,
];
+15 -9
View File
@@ -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):
+96
View File
@@ -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 변경을 감지해야 합니다.
*