139 lines
8.0 KiB
Markdown
139 lines
8.0 KiB
Markdown
---
|
|
title: CI와 Supply Chain을 Skeleton 계약으로 묶기
|
|
source_type: blog
|
|
status: draft
|
|
confidence: unknown
|
|
tags: [blog, ca-tmpl, ci-cd, gradle, supply-chain, reproducible-builds]
|
|
related_projects: [ca-tmpl]
|
|
last_reviewed:
|
|
canonical_sources: []
|
|
audience: backend-engineer
|
|
target_publish:
|
|
status_label: draft
|
|
---
|
|
|
|
# CI와 Supply Chain을 Skeleton 계약으로 묶기
|
|
|
|
> CI에 도구를 붙이는 건 쉽습니다. 어려운 건 "이게 실패하면 누가 책임지는가"를 정하는 일입니다.
|
|
|
|
## TL;DR
|
|
|
|
- CI 파이프라인에 formatter, linter, scanner, SBOM, signing을 순서대로 추가하는 건 어렵지 않지만, **어떤 gate가 release를 막는지, 실패하면 누가 고치는지**를 정해두지 않으면 CI는 금방 장식이 됩니다.
|
|
- ca-tmpl은 이걸 `.github/ci-gate-matrix.yml`이라는 **gate ownership matrix**로 정리하고, 스크립트로 문서와 실제 workflow가 어긋나지 않는지 검사합니다.
|
|
- Supply chain(SBOM, Cosign, SLSA)도 마찬가지로 workflow와 검증 스크립트가 **repo-level에서** 존재합니다. 다만 **실제 hosted CI에서 release를 발행하고 Rekor/GHCR로 검증한 경험은 아직 없습니다** — 이 경계를 이 글에서 분명히 하려 합니다.
|
|
|
|
---
|
|
|
|
## 1. CI는 도구 목록이 아니라 release 계약이다
|
|
|
|
CI를 설계할 때 흔한 실수는 "무엇을 실행할지"만 정하는 것입니다. formatter, linter, unit test, integration test, vulnerability scanner, SBOM, signing, provenance — 순서대로 추가하면 화면은 그럴듯해 보입니다.
|
|
|
|
하지만 실제로 더 중요한 질문은 따로 있습니다.
|
|
|
|
- 이 검사가 실패하면 **release가 막히는가?**
|
|
- **누가** 이 정책을 소유하는가?
|
|
- 문서에 적힌 gate가 **실제 workflow에도 남아 있는가?**
|
|
|
|
이 세 질문에 답하지 못하면, CI는 시간이 지날수록 "돌아는 가는데 아무도 그 의미를 모르는" 상태가 됩니다.
|
|
|
|
---
|
|
|
|
## 2. Gate Matrix — 문서가 아니라 검사 대상
|
|
|
|
ca-tmpl의 `.github/ci-gate-matrix.yml`은 바로 이 질문에 답하기 위한 파일입니다. 각 gate는 다음 정보를 가집니다.
|
|
|
|
```yaml
|
|
gates:
|
|
- id: architecture-test
|
|
release_blocking: true
|
|
owner_branch: feature-architecture-enforcement-rules
|
|
mechanism: contract-test
|
|
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
|
|
runs_in: ci-quality-gates
|
|
```
|
|
|
|
- **release_blocking**: 이 검사가 실패하면 release가 막히는가
|
|
- **owner_branch**: 실패했을 때 누가 고쳐야 하는가
|
|
- **mechanism / ref**: 실제로 무엇으로 구현되어 있는가
|
|
|
|
여기서 중요한 지점은, **이 matrix가 그냥 참고 문서로 끝나지 않는다는 것**입니다. `verify-gate-matrix.sh`가 matrix의 각 row를 읽고, `mechanism`별로 실제 존재 여부를 확인합니다.
|
|
|
|
```bash
|
|
# Cross-checks every row of .github/ci-gate-matrix.yml against reality:
|
|
# gradle-custom-task -> a tasks.register('<ref>') exists
|
|
# contract-test -> the <ref> test-class file exists under src/
|
|
# workflow-job -> the <ref> job id exists in .github/workflows/<runs_in>.yml
|
|
```
|
|
|
|
`gradle-custom-task`라고 적혀 있으면 실제로 `tasks.register('<ref>')`가 존재해야 하고, `contract-test`라고 적혀 있으면 그 test 클래스 파일이 실제로 있어야 하고, `workflow-job`이라고 적혀 있으면 workflow 안에 그 job id가 실제로 존재해야 합니다.
|
|
|
|
이렇게 하면 **"문서에는 gate가 있는데 실제 CI에서는 빠진 상태"**를 줄일 수 있습니다. 흔히 일어나는 일이죠 — 누군가 workflow를 리팩터링하면서 job 이름을 바꿨는데, 문서는 그대로 남아있는 경우요.
|
|
|
|
---
|
|
|
|
## 3. Gradle Baseline — Entropy를 줄이는 것과 증명하는 것은 다르다
|
|
|
|
Gradle 쪽 baseline도 여러 층으로 나뉩니다. `src/build.gradle`은 Spotless, Checkstyle, SpotBugs, ErrorProne을 subproject에 적용하고, dependency locking을 strict mode로 켭니다.
|
|
|
|
```groovy
|
|
dependencyLocking {
|
|
lockAllConfigurations()
|
|
lockMode = LockMode.STRICT
|
|
}
|
|
|
|
tasks.withType(AbstractArchiveTask).configureEach {
|
|
preserveFileTimestamps = false
|
|
reproducibleFileOrder = true
|
|
}
|
|
```
|
|
|
|
archive task는 timestamp, file order, permission을 고정해서 build artifact가 host 환경에 따라 덜 흔들리게 만듭니다.
|
|
|
|
여기서 짚어야 할 경계가 있습니다. **이건 production artifact reproducibility를 완전히 증명한다는 뜻이 아닙니다.** skeleton 단계에서 entropy source(빌드할 때마다 달라질 수 있는 요인)를 줄이는 baseline일 뿐이에요. "재현 가능한 빌드를 만들었다"와 "재현 가능한 빌드의 조건 몇 가지를 미리 고정해뒀다"는 다른 문장입니다.
|
|
|
|
---
|
|
|
|
## 4. Supply Chain — 증거를 digest 중심으로 엮기
|
|
|
|
Supply chain 쪽은 release evidence를 digest 중심으로 묶으려는 방향입니다. `.github/workflows/build-release-supply-chain.yml`에는 SBOM generation, Trivy image scan, Cosign sign/attest, SLSA provenance, verification, promotion step이 순서대로 들어 있습니다.
|
|
|
|
`.github/scripts/verify-supply-chain-contract.sh`는 workflow와 policy 파일에 필요한 문자열과 job wiring이 실제로 남아 있는지 확인합니다.
|
|
|
|
```bash
|
|
require_fixed "${workflow}" 'cosign sign --yes' 'D6 keyless image signature'
|
|
require_fixed "${workflow}" 'cosign attest --yes' 'D4 digest-bound SBOM attestation'
|
|
require_fixed "${workflow}" '--certificate-identity' 'D12 signer identity verification'
|
|
require_fixed "${workflow}" '--certificate-oidc-issuer' 'D12 OIDC issuer verification'
|
|
require_fixed "${workflow}" 'generator_container_slsa3.yml@v2.1.0' 'D7 isolated SLSA generator'
|
|
```
|
|
|
|
Cosign의 keyless 서명, digest에 바인딩된 SBOM attestation, signer identity 검증, OIDC issuer 검증, 그리고 격리된 SLSA generator 사용까지 — 이런 조건들이 workflow에서 실제로 지켜지고 있는지를 스크립트가 확인합니다.
|
|
|
|
### 여기서 가장 중요한 경계선
|
|
|
|
**ca-tmpl에 workflow와 검증 스크립트가 존재한다는 사실**과, **실제 release artifact가 public registry에 올라갔고 Rekor/GHCR evidence로 검증됐다는 사실**은 다릅니다.
|
|
|
|
project canonical은 후자를 확인하지 않았다고 명시적으로 밝힙니다. 그래서 이 글은 **"supply-chain release를 운영했다"가 아니라 "supply-chain release contract를 repo-level workflow와 script로 고정했다"**까지만 말할 수 있습니다.
|
|
|
|
이 구분이 왜 중요하냐면, "Cosign이랑 SLSA를 붙였어요"라는 말만 들으면 이미 실제 release에서 검증까지 끝난 것처럼 들리기 쉽거든요. 하지만 workflow 파일이 존재하는 것과, 그 workflow가 실제로 몇 번 돌아서 서명된 아티팩트가 검증된 것은 완전히 다른 단계의 증거입니다.
|
|
|
|
---
|
|
|
|
## 5. DX — 진입점을 하나로 줄이기
|
|
|
|
DX(Developer Experience)도 같은 관점으로 다룹니다. `./gradlew bootstrap` 명령 하나가 compile, Docker preflight, dependency/migration/startup, sample contract, HTTP smoke test까지를 하나의 진입점으로 묶습니다.
|
|
|
|
이 명령이 **모든 OS와 CI 환경을 보장한다는 뜻은 아닙니다.** 대신 새 프로젝트를 받은 사람이 "무엇부터 실행해야 하나"를 덜 고민하게 만들고, 실패 지점을 단계별로 나눠서 보여주려는 목적입니다.
|
|
|
|
---
|
|
|
|
## 6. 정리 — "도구를 썼다"가 아니라 "어떤 증거가 release를 통과시키는가"
|
|
|
|
결국 ca-tmpl의 DevOps baseline은 "이 도구를 썼다"보다 **"어떤 증거가 release를 통과시키는가"**에 가깝습니다.
|
|
|
|
- gate matrix가 실제 workflow와 어긋나지 않아야 하고
|
|
- dependency lock이 조용히 풀리면 안 되며
|
|
- vulnerability suppression은 사유와 만료일 없이 남아있으면 안 됩니다
|
|
|
|
이 정도가 **local/repo-level에서 검증된 범위**입니다. 실제 hosted CI run, release publication, live Cosign/Rekor 검증은 별도의 근거가 쌓인 뒤에만 말할 수 있는 다음 단계로 남겨둡니다.
|