12 KiB
12 KiB
title, source_type, status, related_branches, related_projects, tags, created, status_label
| title | source_type | status | related_branches | related_projects | tags | created | status_label | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| error / ci-fan-in-skipped-not-failed-and-gitignored-config | error-note | raw |
|
|
|
2026-06-20 | 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 가 된다.
- fan-in
if: success()skip 함정. release-gate aggregator 잡을needs: [...] + if: success()로 짜면, 상위 게이트가 실패 했을 때 aggregator 는failure가 아니라skipped가 된다. branch protection 이 이 잡을 required check 로 잡으면 skipped 를 통과로 오해할 수 있다 → "게이트 1건 실패 → 릴리스 차단" 이 보장되지 않음. - 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. 예측이 아니라 실관측. - 빈 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
- (fan-in) aggregator 잡을
if: success()로 두고 상위 matrix 잡 1개를 의도적 실패시킨다 → aggregator 가 skipped. - (gitignored)
/docs가 gitignore 된 repo 를 fresh checkout(=docs 없음) 후cd src && ./gradlew verifyEnvKeys→verifyEnvKeys: missing .../docs/registries/env-keys.yaml. - (빈 버킷)
@Tag("quarantine")테스트가 하나도 없는 상태에서includeTags 'quarantine'Test 태스크 실행.
근본 원인 / Root cause
- GitHub Actions 의
needs기본 의미: 상위 잡 실패 → 하위 잡은 실행되지 않고skipped.if: success()는 이 기본을 명시한 것일 뿐 — aggregator 를 실패 로 만들지 않는다. branch-note §엣지 "needs/if fan-in status 전파"(Claim C1)가 정확히 이 위험. - 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)의 버그가 아님. - Gradle Test 태스크는 discover 된 테스트가 0이면 기본적으로 실패하는 안전장치가 있다.
Sources / 근거
- 로컬 실행 로그:
verifyQuarantineSunsetover-age positive controlquarantined 170 days ago — past the 14-day sunset/ drift positive controlis @Tag("quarantine") but is not registered. src/build.gradleverifyEnvKeysthrow new GradleException("verifyEnvKeys: missing ${registryFile}")..gitignore/docs(0 tracked:git ls-files docs/ | wc -l→ 0).SampleRemovalSmokeContractTestline 95 verbatim: "docs/registries/env-keys.yaml not on disk (/docs is gitignored)" — 같은 제약을 다른 테스트가 graceful skip 으로 처리하는 선례.
해결 / Resolution
- 적용한 조치:
- fan-in: release-gate 를
if: always()+needs.*.result스캔(grep -Eq '"result"..."(failure|cancelled)"')으로 구현 → 상위 1건 실패 시 aggregator 가 fail 로 차단. skipped(예: push 이벤트의 PR-only 잡)는 OK 로 통과.quarantine잡은 의도적으로needs에서 제외(비차단). - gitignored (1차, 문서화): 본 branch 가 새로 추가하는 CI-read 파일(
flaky-quarantine.yaml,.github/ci-gate-matrix.yml)은docs/가 아니라 tracked 경로(repo 루트 /.github/)에 둠 —.trivyignore.yaml선례. - gitignored (2차, 실관측 후 — 사용자 결정 Option 1): CI 러너에서 verifyEnvKeys 실패가 실제로 터진 뒤, 핵심 판단을 재검토. registry 의존 게이트를 "부재 시 skip" 으로 완화하면 CI green 이지만 CI 게이트가 vacuous(registry 계약을 실제로 강제 못 함) → 이 branch 의 목표("계약을 CI 에서 강제")가 무력화. 조사 결과 docs 읽는 contract 테스트 18/21 이 이미
assumeTrueskip-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 에서 실제 강제. - 빈 버킷:
quarantineTest에failOnNoDiscoveredTests = false(Gradle 8+ Test 속성) → 빈 버킷 통과. 로컬:shared-contract:quarantineTestBUILD SUCCESSFUL 로 확인.
- fan-in: release-gate 를
- 검증 방법: 위 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/RunbookCoverageContractTest5건이 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)만 둔다.
- CI fan-in aggregator 는
추가 함정 (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.xmlASYNC_ENABLEDdefaultValue=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 verifyPublicPathSnapshotBUILD 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 만 검사하므로 거버넌스는 사람이 지켜야 함.
- 테스트 JVM 에서 비동기 로깅 +