Files
llm-wiki/raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20.md
T

92 lines
12 KiB
Markdown

---
title: error / ci-fan-in-skipped-not-failed-and-gitignored-config
source_type: error-note
status: raw
related_branches: [feature-ci-quality-gates-contract]
related_projects: [ca-skeleton]
tags: [error, ca-skeleton, ci, github-actions, gradle]
created: 2026-06-20
status_label: resolved
---
# error: ci-fan-in-skipped-not-failed-and-gitignored-config
> Layer: `raw/errors/` — 단일 실패·트러블슈팅 기록(이번엔 *구현 중 회피한* 함정 3종).
> `status_label`: `resolved` (CI 실제 실행은 `needs-confirmation` — 로컬 검증까지만)
## Parent / 부모
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — gate wiring 구현 중 마주친 3가지 함정. 모두 코드로 회피했으나 재발 위험이 있어 기록.
## 증상 / Symptom
세 가지 별개의 함정. 잘못 짰다면 "게이트가 통과한 것처럼 보이지만 실제로는 차단되지 않는" silent failure 가 된다.
1. **fan-in `if: success()` skip 함정.** release-gate aggregator 잡을 `needs: [...] + if: success()` 로 짜면, 상위 게이트가 *실패* 했을 때 aggregator 는 `failure` 가 아니라 **`skipped`** 가 된다. branch protection 이 이 잡을 required check 로 잡으면 skipped 를 통과로 오해할 수 있다 → "게이트 1건 실패 → 릴리스 차단" 이 보장되지 않음.
2. **gitignored 런타임 설정 → CI 에서 `check` 실패.** `verifyEnvKeys``docs/registries/env-keys.yaml` 부재 시 `throw new GradleException(...)`. 그런데 ca-tmpl `.gitignore``/docs` 전체를 제외(0 tracked). fresh CI checkout 에는 registry 가 없으므로 `./gradlew check` 가 verifyEnvKeys 에서 실패. **2026-06-20 실제 CI 러너에서 확인됨**(원문): `verifyEnvKeys: missing /workspace/.../ca-tmpl/docs/registries/env-keys.yaml``BUILD FAILED`. 예측이 아니라 실관측.
3. **빈 tag 버킷 Test 태스크 실패.** `quarantineTest``useJUnitPlatform { includeTags 'quarantine' }` 로 만들면, 매칭되는 테스트가 0개일 때(스켈레톤 기본) Test 태스크가 "no tests" 로 실패할 수 있다.
- 발생 컨텍스트: feature-ci-quality-gates-contract gate wiring 구현 (로컬, Gradle 9.0.0 / Java 21).
- 재현 가능 여부: `always` (설계상 결정 — 잘못 짜면 항상 재현).
## 재현 절차 / Reproduction
1. (fan-in) aggregator 잡을 `if: success()` 로 두고 상위 matrix 잡 1개를 의도적 실패시킨다 → aggregator 가 skipped.
2. (gitignored) `/docs` 가 gitignore 된 repo 를 fresh checkout(=docs 없음) 후 `cd src && ./gradlew verifyEnvKeys``verifyEnvKeys: missing .../docs/registries/env-keys.yaml`.
3. (빈 버킷) `@Tag("quarantine")` 테스트가 하나도 없는 상태에서 `includeTags 'quarantine'` Test 태스크 실행.
## 근본 원인 / Root cause
1. GitHub Actions 의 `needs` 기본 의미: 상위 잡 실패 → 하위 잡은 실행되지 않고 `skipped`. `if: success()` 는 이 기본을 명시한 것일 뿐 — aggregator 를 *실패* 로 만들지 않는다. branch-note §엣지 "needs/if fan-in status 전파"(Claim C1)가 정확히 이 위험.
2. ca-tmpl 은 *템플릿 개발 repo*`/docs`(registries·superpowers·wiki 산출물)를 gitignore 한다. 어댑터가 fork 시 registry 를 커밋하면 `check` 가 통과하지만, 이 dev repo 에서 그대로 CI 를 켜면 실패. `verifyEnvKeys` 의 throw-on-missing 동작은 `feature-env-driven-runtime-configuration` 소유 — 본 branch(gate wiring)의 버그가 아님.
3. Gradle Test 태스크는 discover 된 테스트가 0이면 기본적으로 실패하는 안전장치가 있다.
## Sources / 근거
- 로컬 실행 로그: `verifyQuarantineSunset` over-age positive control `quarantined 170 days ago — past the 14-day sunset` / drift positive control `is @Tag("quarantine") but is not registered`.
- `src/build.gradle` verifyEnvKeys `throw new GradleException("verifyEnvKeys: missing ${registryFile}")`.
- `.gitignore` `/docs` (0 tracked: `git ls-files docs/ | wc -l` → 0).
- `SampleRemovalSmokeContractTest` line 95 verbatim: "docs/registries/env-keys.yaml not on disk (/docs is gitignored)" — 같은 제약을 다른 테스트가 graceful skip 으로 처리하는 선례.
## 해결 / Resolution
- 적용한 조치:
1. **fan-in:** release-gate 를 `if: always()` + `needs.*.result` 스캔(`grep -Eq '"result"..."(failure|cancelled)"'`)으로 구현 → 상위 1건 실패 시 aggregator 가 *fail* 로 차단. skipped(예: push 이벤트의 PR-only 잡)는 OK 로 통과. `quarantine` 잡은 의도적으로 `needs` 에서 제외(비차단).
2. **gitignored (1차, 문서화):** 본 branch 가 새로 추가하는 *CI-read* 파일(`flaky-quarantine.yaml`, `.github/ci-gate-matrix.yml`)은 `docs/` 가 아니라 **tracked 경로**(repo 루트 / `.github/`)에 둠 — `.trivyignore.yaml` 선례.
3. **gitignored (2차, 실관측 후 — 사용자 결정 Option 1):** CI 러너에서 verifyEnvKeys 실패가 실제로 터진 뒤, 핵심 판단을 재검토. registry 의존 게이트를 "부재 시 skip" 으로 완화하면 CI green 이지만 **CI 게이트가 vacuous**(registry 계약을 실제로 강제 못 함) → 이 branch 의 목표("계약을 CI 에서 강제")가 무력화. 조사 결과 docs 읽는 contract 테스트 **18/21 이 이미 `assumeTrue` skip-tolerant**, verifyEnvKeys 만 throw 하는 outlier. 사용자에게 옵션 제시 → **Option 1(registries 커밋)** 채택: `.gitignore``/docs/*` + `!/docs/registries/` 로 좁혀 **운영 레지스트리 7개만 추적**(superpowers/security/runbooks/optional 은 계속 private). `secrets-classification.yaml` 은 분류 메타(값 아님, `prod_default: null`)라 커밋 안전. → 게이트가 fresh checkout 에서 실제 강제.
3. **빈 버킷:** `quarantineTest``failOnNoDiscoveredTests = false`(Gradle 8+ Test 속성) → 빈 버킷 통과. 로컬 `:shared-contract:quarantineTest` BUILD SUCCESSFUL 로 확인.
- 검증 방법: 위 3종 positive/negative control 로컬 실행; gate-matrix lint PASS(20 게이트, 16 verified + 4 delegated); workflow YAML PyYAML 파싱 OK + release-gate needs 에 quarantine 부재 assert.
- 잔여 위험 / 후속: **CI 실제 실행 `needs-confirmation`** — GitHub.com/Gitea 러너에서 release-gate fail-on-failure 동작과 `check` 의 registry 의존을 실측해야 함(branch-note Claim C1).
## 회고 / Lessons
- 빠르게 감지하는 신호:
- aggregator 잡을 required check 로 잡기 전 **상위 1개를 일부러 실패**시켜 *fail 인지 skip 인지* 확인. skip 이면 차단 안 됨.
- `verifyEnvKeys: missing .../docs/...` → CI 가 gitignored 설정에 의존. CI-read 파일은 tracked 경로로.
- 예방 체크리스트 후보:
- CI fan-in aggregator 는 `always()` + result 스캔. `success()` 단독 금지.
- 새 거버넌스 파일은 "CI 가 읽나?" → 읽으면 절대 `docs/`(gitignore) 에 두지 않는다.
- tag-filter Test 태스크는 `failOnNoDiscoveredTests = false`.
- **"부재 시 skip" 게이트 = CI 에서 vacuous.** 계약을 *강제* 하려는 게이트의 입력(레지스트리)은 반드시 추적되어야 한다. gitignore 로 입력을 빼면서 게이트가 통과하면, 그 게이트는 "강제" 가 아니라 "통과 연기" 다. CI green ≠ 게이트 작동.
- **부분 un-ignore 는 2차 의존을 드러낸다 (skip→fail 전환 함정).** registries 만 커밋하자 `BackgroundJobErrorCodeContractTest`/`RunbookCoverageContractTest` 5건이 *skip 에서 fail 로* 바뀜 — skip 가드는 registry 부재에만 걸려 있었고, registry 가 생기자 가드를 통과한 뒤 `docs/runbooks/*.md` 존재를 단언(`Files.exists`)하다 dangling 으로 실패. 교훈: build-input docs 를 un-ignore 할 때 **한 디렉터리만 풀지 말고, 그 게이트들이 읽는 입력 전체(registries + runbooks)를 함께** 풀어야 한다. 로컬 재현법: `mv docs/runbooks /tmp; ./gradlew :app-bootstrap:test --tests '*Runbook*' --tests '*BackgroundJobErrorCode*'` → 5 failed 재현.
- **breaking-change governed 목록은 "contract snapshot" 으로 좁혀라.** `.github/ci-gate-matrix.yml`(config)을 D8 governed 정규식에 넣었더니, 매트릭스를 *처음 만든* 그 PR 이 `intent:breaking-change-approved` 라벨을 강요당해 `breaking-change-approval` 잡이 fail. config 는 CODEOWNERS + gate-matrix-lint 로 보호하고, governed 는 OpenAPI 스냅샷·ApprovalTests `*.approved.*`(실제 계약 baseline)만 둔다.
## 추가 함정 (4) — 2026-06-20 CI 3차: flaky `CapturedOutput` + async logback → quarantine
> 앞의 3종은 구현 중 *회피*했으나, 이건 게이트가 실제 CI 에서 *잡아내* quarantine 으로 처리한 첫 사례.
- **증상.** full `./gradlew check` 에서 `PrivacySettingsTest.blankSalt_warnsAndFallsBackToDevSentinel(CapturedOutput)` 1건만 실패(`487 tests, 1 failed`), `release-gate``quality-gates: failure` 감지·차단(Claim C1 재실증). 로컬 단독·full 모두 통과 → 순서 의존 flaky.
- **근본 원인.** `logback-spring.xml` `ASYNC_ENABLED` defaultValue=`true``MetricsAsyncAppender` 가 root 콘솔을 비동기로 감쌈. 같은 모듈 sibling `@SpringBootTest`(ActuatorSecurityHttpTest 등)가 Spring Boot 로깅 초기화로 이 async appender 를 **JVM-전역 logback 컨텍스트**에 설치 → 이후 경량 `ApplicationContextRunner` 테스트의 `log.warn` 이 worker 스레드에서 flush 되는데, `output.getOut()` 단언은 동기적으로 즉시 읽음 → race. Gradle 테스트 클래스 순서가 머신마다 달라 CI 만 지는 순서를 뽑음. (마스킹/JSON 인코딩은 무관 — 단언 문자열에 escape 대상이 없어 `contains` 가 그대로 매칭.)
- **해결(이 branch 메커니즘 첫 실사용).** flaky 한 `blankSalt` *메서드에만* `@Tag("quarantine")`(realSalt 는 경고 미발생이라 async 무관) + `flaky-quarantine.yaml` 등록(reason + tracking_issue + `quarantined_since`, 14d sunset). drift guard 는 태그된 파일의 첫 class 이름을 simple-name suffix 로 레지스트리와 매칭(`build.gradle:670`)하므로 메서드-단위 태그 + `#method` 등록이 정합. `test``excludeTags 'quarantine'` 로 제외, `quarantineTest` 가 비차단 실행. 검증: `verifyQuarantineSunset OK(1 registered/1 tagged)`, `quarantineTest tests=1 failures=0`, `check verifyPublicPathSnapshot` BUILD SUCCESSFUL.
- **교훈 / 예방.**
- **테스트 JVM 에서 비동기 로깅 + `CapturedOutput` = 구조적 flaky.** `CapturedOutput` 은 프로세스-전역 `System.out` 을 가로채므로, full-boot 테스트가 설치한 async appender 와 항상 race 한다. 모듈에 `@SpringBootTest``CapturedOutput` 단언이 공존하면 `logback-test.xml`(async-off) 로 test 시 동기화하거나, 로거에 `ListAppender` 를 붙여 단언하라 — stdout 캡처 race 자체를 제거.
- **잠복 동형 위험을 함께 기록하라.** 같은 모듈 `LoggingSettingsTest`(`badTimezone/badAsyncQueueSize_warnsAndFallsBack`)도 동일 패턴 — 이번엔 미발생이나 다른 순서에서 재현 가능. quarantine 은 whack-a-mole 을 부르므로 근본수정을 sunset 안에.
- **quarantine 은 주차장이 아니다.** `tracking_issue` 플레이스홀더(TODO)는 머지 전 실제 이슈로 교체 — 게이트는 non-empty 만 검사하므로 거버넌스는 사람이 지켜야 함.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-ci-quality-gates-contract]]
- 관련 blog topic: [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]]
- 관련 interview: [[raw/interviews/ci-release-gate-fan-in-blocking-2026-06-20]]
- 선행 CI 트러블슈팅: [[raw/errors/gitea-act-action-tag-and-dependency-graph-2026-06-20]]