diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index f6905b19..983bc5e2 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -109,20 +109,35 @@ class ExecuteUpgradeStepsCommand extends Command ]); } + // 코어 업데이트 spawn 자식 감지. + // + // CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식에 `G7_UPDATE_IN_PROGRESS=1` env 를 + // 전달한다 (beta.3 부터 존재). 이 시그널은 자식이 부모의 `core:update` 컨텍스트에서 + // 실행 중임을 의미한다. 부모는 본인이 사전(Step 9 migration + resync) + 사후 + // (Step 11 version-env + cache-clear + bundled prompt) 단계를 모두 책임지므로 자식은 + // 5단계 모두 스킵해야 한다. + // + // 구버전 부모(예: beta.5)는 신버전 자식이 도입한 `--skip-*` 옵션을 모른다. 옵션 부재 + // 시 자식이 사후 단계를 실행하면 (특히 bundled prompt) non-interactive 환경에서 + // `Aborted.` exit=1 회귀 발생 (882deb9b0 사각지대). env 시그널이 옵션 부재를 보완. + // + // 단독 호출 (운영자 수동) 시엔 env 미설정 → 모든 단계 정상 진행. + $isSpawnChild = getenv('G7_UPDATE_IN_PROGRESS') === '1'; + // 사전 단계 (단독 실행 안전성 보장). // // CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서 // 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피. // 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 → // migration / permission / menu / seeder 누락 차단. - if (! $this->option('skip-migrations')) { + if (! $this->option('skip-migrations') && ! $isSpawnChild) { $this->info('마이그레이션 실행'); $service->runMigrations(); } else { $this->info('[spawn] 마이그레이션 스킵 — 부모가 이미 실행'); } - if (! $this->option('skip-resync')) { + if (! $this->option('skip-resync') && ! $isSpawnChild) { $this->info('코어 config 재로드 및 권한/메뉴/시더 동기화'); $service->reloadCoreConfigAndResync(); } else { @@ -194,21 +209,21 @@ class ExecuteUpgradeStepsCommand extends Command // --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피. // 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차 // (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합. - if (! $this->option('skip-version-env')) { + if (! $this->option('skip-version-env') && ! $isSpawnChild) { $this->info(".env APP_VERSION={$to} 갱신"); $service->updateVersionInEnv($to); } else { $this->info('[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리'); } - if (! $this->option('skip-cache-clear')) { + if (! $this->option('skip-cache-clear') && ! $isSpawnChild) { $this->info('캐시 정리 (config/route/view/services/packages)'); $service->clearAllCaches(); } else { $this->info('[spawn] 캐시 정리 스킵 — 부모가 처리'); } - if (! $this->option('skip-bundled-updates')) { + if (! $this->option('skip-bundled-updates') && ! $isSpawnChild) { $this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)'); $this->runBundledExtensionUpdatePrompt( app(ModuleManager::class), diff --git a/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php b/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php index 1635cf91..a549f02f 100644 --- a/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php +++ b/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php @@ -181,6 +181,97 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase $this->assertSame(0, $exitCode); } + public function test_spawn_child_env_bypasses_all_pre_and_post_steps_without_skip_options(): void + { + // 구버전 부모(beta.5) 가 신버전 자식(beta.6+) 을 spawn 할 때 `--skip-*` 옵션을 모르므로 + // 전달하지 않지만, `G7_UPDATE_IN_PROGRESS=1` env 는 beta.3+ 부모부터 항상 설정한다. + // 자식은 env 시그널만으로 본인이 spawn 컨텍스트임을 감지하여 5단계 모두 자동 스킵해야 + // 한다 (옵션 부재 + non-interactive 환경에서 prompt 호출로 인한 Aborted exit=1 회귀 차단). + [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); + + $service->shouldNotReceive('runMigrations'); + $service->shouldNotReceive('reloadCoreConfigAndResync'); + $service->shouldReceive('runUpgradeSteps')->once(); + $service->shouldNotReceive('updateVersionInEnv'); + $service->shouldNotReceive('clearAllCaches'); + $service->shouldNotReceive('collectBundledExtensionUpdates'); + $langPack->shouldNotReceive('collectBundledLangPackUpdates'); + + $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); + putenv('G7_UPDATE_IN_PROGRESS=1'); + + try { + $exitCode = $this->runCommand([]); + $this->assertSame(0, $exitCode); + } finally { + if ($originalEnv === false) { + putenv('G7_UPDATE_IN_PROGRESS'); + } else { + putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); + } + } + } + + public function test_spawn_child_env_skips_even_when_force_is_set(): void + { + // env=1 + --force 동시 적용 시에도 사후 단계 모두 스킵. force 는 자식이 실행하는 step + // 자체의 동일 버전 강제 플래그로만 사용되며, env 시그널이 우선한다 (부모가 force 든 + // 아니든 부모가 5단계를 책임진다는 계약 보존). + [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); + + $service->shouldNotReceive('runMigrations'); + $service->shouldNotReceive('reloadCoreConfigAndResync'); + $service->shouldReceive('runUpgradeSteps')->once(); + $service->shouldNotReceive('updateVersionInEnv'); + $service->shouldNotReceive('clearAllCaches'); + $service->shouldNotReceive('collectBundledExtensionUpdates'); + $langPack->shouldNotReceive('collectBundledLangPackUpdates'); + + $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); + putenv('G7_UPDATE_IN_PROGRESS=1'); + + try { + $exitCode = $this->runCommand(['--force' => true]); + $this->assertSame(0, $exitCode); + } finally { + if ($originalEnv === false) { + putenv('G7_UPDATE_IN_PROGRESS'); + } else { + putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); + } + } + } + + public function test_standalone_invocation_without_env_still_runs_all_steps(): void + { + // env 미설정 (운영자 수동 호출) 시 기존 단독 실행 계약 보존 — 5단계 모두 실행. + // env 가드 도입이 단독 실행 경로에 회귀를 일으키지 않음을 확인. + [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); + + $service->shouldReceive('runMigrations')->once(); + $service->shouldReceive('reloadCoreConfigAndResync')->once(); + $service->shouldReceive('runUpgradeSteps')->once(); + $service->shouldReceive('updateVersionInEnv')->once(); + $service->shouldReceive('clearAllCaches')->once(); + $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ + 'modules' => [], 'plugins' => [], 'templates' => [], + ]); + $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); + + // env 가 설정되지 않은 상태임을 명시적으로 보장. + $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); + putenv('G7_UPDATE_IN_PROGRESS'); + + try { + $exitCode = $this->runCommand([]); + $this->assertSame(0, $exitCode); + } finally { + if ($originalEnv !== false) { + putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); + } + } + } + public function test_spawn_passes_all_five_skip_options_to_child(): void { // 부모 CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식 command 배열에 diff --git a/tests/scenarios/core-execute-upgrade-steps-standalone.yaml b/tests/scenarios/core-execute-upgrade-steps-standalone.yaml index 9803e73d..96697279 100644 --- a/tests/scenarios/core-execute-upgrade-steps-standalone.yaml +++ b/tests/scenarios/core-execute-upgrade-steps-standalone.yaml @@ -14,30 +14,44 @@ description: | 본 매니페스트는 5개 누락 단계를 자식 기본값에 포함시키고, 부모 spawn 호출 시엔 5개 `--skip-*` 옵션으로 중복 회피하는 cross product 의 회귀 가드. + 추가 가드 (2026-05-14): 구버전 부모(beta.5 이하) 가 신버전 자식(beta.6+) 을 spawn 할 때 + `--skip-*` 5개 옵션은 모르지만 `G7_UPDATE_IN_PROGRESS=1` env 는 beta.3+ 부모부터 + 항상 설정한다. 자식은 env 시그널만으로도 spawn 컨텍스트를 감지하여 5단계 모두 스킵해야 + 한다 (옵션 부재 + non-interactive 환경에서 prompt 호출로 인한 Aborted exit=1 회귀 차단). + 구성: 1. 5개 사전·사후 단계 각각의 단독 호출 / `--skip-*` 옵션 분기 검증. 2. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess` 가 자식 command 배열에 5개 옵션을 모두 전달하는지 (escapeshellarg 후 commandLine 문자열 검증). 3. `runUpgradeSteps` 본 임무는 항상 호출 (옵션 무관). 4. 5개 옵션을 모두 전달한 경우 CoreUpdateCommand spawn 시나리오 등가 — 사전·사후 미수행. + 5. `G7_UPDATE_IN_PROGRESS=1` env 만으로도 5단계 모두 스킵 (구버전 부모 V-1 안전 가드). axes: - invocation_origin: [standalone_operator, parent_spawn] # 누가 호출했는가 + invocation_origin: [standalone_operator, parent_spawn_with_skip_options, parent_spawn_env_only] # 누가 호출했는가 skip_migrations: [present, absent] skip_resync: [present, absent] skip_version_env: [present, absent] skip_cache_clear: [present, absent] skip_bundled_updates: [present, absent] + spawn_env_flag: [present, absent] # G7_UPDATE_IN_PROGRESS=1 시그널 force_flag: [present, absent] # bundled prompt 우회 여부 bundled_updates_count: [zero, modules_only, plugins_only, templates_only, lang_packs_only, mixed] from_to_relation: [from_lt_to, from_eq_to_forced] # version_compare 분기 exclusions: - - { invocation_origin: parent_spawn, skip_migrations: absent, reason: "부모 spawn 시엔 5개 옵션 모두 전달 — 일부 절단 시뮬은 인위적 비현실 분기" } - - { invocation_origin: parent_spawn, skip_resync: absent, reason: "동일 — 부모 spawn 시 5개 옵션 일괄 전달" } - - { invocation_origin: parent_spawn, skip_version_env: absent, reason: "동일" } - - { invocation_origin: parent_spawn, skip_cache_clear: absent, reason: "동일" } - - { invocation_origin: parent_spawn, skip_bundled_updates: absent, reason: "동일" } + - { invocation_origin: parent_spawn_with_skip_options, skip_migrations: absent, reason: "부모(beta.6+) spawn 시엔 5개 옵션 모두 전달 — 일부 절단 시뮬은 인위적 비현실 분기" } + - { invocation_origin: parent_spawn_with_skip_options, skip_resync: absent, reason: "동일 — 부모 spawn 시 5개 옵션 일괄 전달" } + - { invocation_origin: parent_spawn_with_skip_options, skip_version_env: absent, reason: "동일" } + - { invocation_origin: parent_spawn_with_skip_options, skip_cache_clear: absent, reason: "동일" } + - { invocation_origin: parent_spawn_with_skip_options, skip_bundled_updates: absent, reason: "동일" } + - { invocation_origin: parent_spawn_env_only, skip_migrations: present, reason: "구버전 부모(beta.5) 는 5개 옵션을 모르므로 전달하지 않음 — 본 분기는 env 가드 단독 효과 검증" } + - { invocation_origin: parent_spawn_env_only, skip_resync: present, reason: "동일" } + - { invocation_origin: parent_spawn_env_only, skip_version_env: present, reason: "동일" } + - { invocation_origin: parent_spawn_env_only, skip_cache_clear: present, reason: "동일" } + - { invocation_origin: parent_spawn_env_only, skip_bundled_updates: present, reason: "동일" } + - { invocation_origin: parent_spawn_env_only, spawn_env_flag: absent, reason: "정의상 env_only 분기는 env=1 필수" } + - { invocation_origin: standalone_operator, spawn_env_flag: present, reason: "운영자 수동 호출은 env 미설정 — env 설정 시는 parent_spawn 분기로 분류" } - { invocation_origin: standalone_operator, bundled_updates_count: zero, skip_bundled_updates: absent, force_flag: absent, reason: "0건 + 옵션 미사용 + force 미사용 시 prompt 발동되나 본 매트릭스의 회귀 대상은 옵션 분기뿐" } effects: @@ -63,6 +77,14 @@ effects: - CoreUpdateCommand_spawnUpgradeStepsProcess_appends_skip_bundled_updates_to_command_array # §5 운영자 안내문 (resumeCommand) 호환성 — 옵션 없이도 기본값으로 안전 실행 - resume_command_without_skip_options_invokes_all_five_pre_and_post_steps_safely + # §6 구버전 부모 V-1 안전 가드 (env 시그널 단독) + - ExecuteUpgradeStepsCommand_skips_runMigrations_when_spawn_env_flag_present_without_skip_options + - ExecuteUpgradeStepsCommand_skips_reloadCoreConfigAndResync_when_spawn_env_flag_present_without_skip_options + - ExecuteUpgradeStepsCommand_skips_updateVersionInEnv_when_spawn_env_flag_present_without_skip_options + - ExecuteUpgradeStepsCommand_skips_clearAllCaches_when_spawn_env_flag_present_without_skip_options + - ExecuteUpgradeStepsCommand_skips_bundled_updates_when_spawn_env_flag_present_without_skip_options + - ExecuteUpgradeStepsCommand_env_guard_dominates_over_force_flag_for_post_steps + - ExecuteUpgradeStepsCommand_standalone_invocation_without_env_runs_all_five_steps_intact test_files: - tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php