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

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
feature-ci-quality-gates-contract
ca-skeleton
error
ca-skeleton
ci
github-actions
gradle
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 / 부모

증상 / 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 실패. verifyEnvKeysdocs/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.yamlBUILD FAILED. 예측이 아니라 실관측.
  3. 빈 tag 버킷 Test 태스크 실패. quarantineTestuseJUnitPlatform { 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 verifyEnvKeysverifyEnvKeys: 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 에서 실제 강제.
    4. 빈 버킷: quarantineTestfailOnNoDiscoveredTests = 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-gatequality-gates: failure 감지·차단(Claim C1 재실증). 로컬 단독·full 모두 통과 → 순서 의존 flaky.
  • 근본 원인. logback-spring.xml ASYNC_ENABLED defaultValue=trueMetricsAsyncAppender 가 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 등록이 정합. testexcludeTags '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 한다. 모듈에 @SpringBootTestCapturedOutput 단언이 공존하면 logback-test.xml(async-off) 로 test 시 동기화하거나, 로거에 ListAppender 를 붙여 단언하라 — stdout 캡처 race 자체를 제거.
    • 잠복 동형 위험을 함께 기록하라. 같은 모듈 LoggingSettingsTest(badTimezone/badAsyncQueueSize_warnsAndFallsBack)도 동일 패턴 — 이번엔 미발생이나 다른 순서에서 재현 가능. quarantine 은 whack-a-mole 을 부르므로 근본수정을 sunset 안에.
    • quarantine 은 주차장이 아니다. tracking_issue 플레이스홀더(TODO)는 머지 전 실제 이슈로 교체 — 게이트는 non-empty 만 검사하므로 거버넌스는 사람이 지켜야 함.