update: security-distribution phase and certified build scripts

This commit is contained in:
toki 2026-06-17 09:30:46 +09:00
parent 1aded8d618
commit 03df5934f8
5 changed files with 91 additions and 23 deletions

View file

@ -11,7 +11,7 @@ OTO 없이 Jenkins job만 얇게 구성해 AppSok 인증 ZIP을 반복 빌드하
## 상태
[진행중]
[완료]
## 승격 조건
@ -20,6 +20,7 @@ OTO 없이 Jenkins job만 얇게 구성해 AppSok 인증 ZIP을 반복 빌드하
## 구현 잠금
- 상태: 해제
- SDD: 불필요 - Jenkins thin job 설정, 문서화, 1회 smoke evidence로 닫히는 운영 작업이며 앱 API/proto/schema 계약을 바꾸지 않는다.
- 결정 필요: 아래 체크리스트
- [x] Jenkins job 생성/갱신은 사용자 제공 Jenkins API credential을 사용한다. 원격 SOPS 저장과 Jenkins API 인증 확인을 완료했다.
- [x] artifact 다운로드는 Jenkins 로그인 사용자에게만 허용한다. 별도 공개 다운로드 경로는 이번 마일스톤 범위에서 만들지 않는다.
@ -50,20 +51,20 @@ Jenkins job은 AppSok 빌드 스크립트를 호출하고 artifact를 보관하
사용자가 Jenkins에서 빌드를 실행하고 최신 성공 artifact를 확인할 수 있게 한다.
- [ ] [trigger] 1차 trigger 방식을 적용한다.
- [ ] [download-url] `lastSuccessfulBuild/artifact/.../AppSok-certified.zip` 다운로드 URL을 확인하고 문서화한다.
- [ ] [auth-policy] Jenkins artifact 다운로드 권한 정책을 결정 사항에 맞게 반영한다.
- [ ] [smoke] Jenkins build를 1회 실행하고 archived ZIP과 checksum을 다운로드 가능한 상태로 검증한다. 검증: Jenkins 최신 성공 빌드의 artifact URL이 인증된 사용자 기준으로 ZIP 응답을 반환한다.
- [x] [trigger] 1차 trigger 방식을 적용한다.
- [x] [download-url] `lastSuccessfulBuild/artifact/.../AppSok-certified.zip` 다운로드 URL을 확인하고 문서화한다.
- [x] [auth-policy] Jenkins artifact 다운로드 권한 정책을 결정 사항에 맞게 반영한다.
- [x] [smoke] Jenkins build를 1회 실행하고 archived ZIP과 checksum을 다운로드 가능한 상태로 검증한다. 검증: Jenkins 최신 성공 빌드의 artifact URL이 인증된 사용자 기준으로 ZIP 응답을 반환한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증 충족 후 기록한다.
- 상태: 승인됨
- 요청일: 2026-06-17
- 완료 근거: Jenkins job 설정 apply가 성공했고, Jenkins build #2가 SUCCESS로 종료되었다. 최신 성공 빌드의 `AppSok-certified.zip``.sha256` artifact가 인증 사용자 기준 HTTP 200으로 다운로드되었고 ZIP size는 24,326,080 bytes, sha256은 `557339ba024afaedc614a5a5d88716a5d7daa4882c88903155e3318cc6363104`로 확인되었다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- [x] 사용자가 완료 결과를 확인했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: 사용자가 빌드 자체 정상 확인을 완료했고, 기능적인 부분은 추후 확인하기로 했다.
## 범위 제외
@ -78,6 +79,8 @@ Jenkins job은 AppSok 빌드 스크립트를 호출하고 artifact를 보관하
- 관련 경로: `scripts/build-certified-macos.sh`, `docs/macos-certified-build.md`, `agent-roadmap/phase/security-distribution/`
- 표준선(선택): Jenkins는 1차로 thin launcher와 artifact archive만 담당하고, 빌드 절차의 source of truth는 AppSok repository의 스크립트와 문서에 둔다.
- 표준선(선택): Jenkins API token, SSH credential, Apple credential, Keychain password 원문은 tracked 파일, task log, 최종 응답에 기록하지 않는다.
- 진행 현황: `trigger`, `download-url`, `auth-policy`는 Jenkins job XML dry-run, XML parse, 문서화, 스크립트 테스트, `flutter analyze`, `flutter test` evidence로 반영했다.
- 진행 현황: `smoke`는 Jenkins job apply, build #2 SUCCESS, 인증 사용자 기준 ZIP/checksum HTTP 200 응답으로 반영했다.
- 선행 작업: macOS 빌드 CI와 인증 빌드
- 후속 작업: macOS 배포와 sandbox 검증, 향후 OTO 기반 CI/CD 마이그레이션 후보
- 확인 필요: 없음

View file

@ -14,8 +14,8 @@ AppSok을 개인용 도구에서 사내 표준 도구로 확장하기 위한 배
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다.
- [진행중] Jenkins 얇은 빌드와 artifact 다운로드
- 경로: `agent-roadmap/phase/security-distribution/milestones/jenkins-thin-artifact-ci.md`
- [완료] Jenkins 얇은 빌드와 artifact 다운로드
- 경로: `agent-roadmap/archive/phase/security-distribution/milestones/jenkins-thin-artifact-ci.md`
- 요약: OTO 없이 Jenkins job만 얇게 구성해 AppSok 인증 ZIP을 빌드하고 Jenkins artifact 링크로 바로 다운로드할 수 있게 한다.
- [계획] macOS 배포와 sandbox 검증
- 경로: `agent-roadmap/phase/security-distribution/milestones/macos-distribution.md`

View file

@ -39,6 +39,19 @@ raw password, app-specific password, Jenkins API token, private endpoint, decryp
2. **Apply**: `--apply` 플래그를 주면 `secrets/appsok.ci.sops.json`에 저장된 `jenkins_url`, `jenkins_username`, `jenkins_api_token`을 SOPS를 이용해 복호화한 뒤 Jenkins API(POST `/createItem` 혹은 POST `/config.xml`)를 호출하여 설정을 적용한다.
3. **Secret 비노출**: Jenkins API Token 및 credential 값은 job 설정 XML 내부에 보관되거나 repository에 커밋되지 않으며, 실행 중에도 노출되지 않는다.
### Trigger와 artifact access
1차 trigger는 Jenkins GitHub push webhook이다. job 설정 XML은 GitHub push trigger와 SCM branch spec `*/main`을 함께 사용하므로, `develop`에서 `main`으로 merge된 뒤 `main` push webhook이 들어올 때 인증 ZIP 빌드를 실행한다. cron polling trigger는 사용하지 않는다.
최신 성공 빌드 artifact URL은 Jenkins base URL 뒤에 아래 경로를 붙인다. Jenkins URL, username, API token 원문은 문서나 task log에 남기지 않는다.
```text
/job/appsok-macos-certified/lastSuccessfulBuild/artifact/build/macos/Build/Products/Release/AppSok-certified.zip
/job/appsok-macos-certified/lastSuccessfulBuild/artifact/build/macos/Build/Products/Release/AppSok-certified.zip.sha256
```
artifact 다운로드는 Jenkins 로그인 사용자 또는 사용자별 API token 인증으로만 허용한다. 공개/anonymous 다운로드 경로와 URL query token 방식은 이번 마일스톤 범위에서 사용하지 않는다.
## 인증 빌드
설정이 끝난 뒤 반복 빌드는 아래 한 줄로 실행한다.

View file

@ -62,6 +62,11 @@ XML_CONTENT=$(cat <<EOF
<artifactDaysToKeep>-1</artifactDaysToKeep>
<artifactNumToKeep>10</artifactNumToKeep>
</logRotator>
<triggers>
<com.cloudbees.jenkins.GitHubPushTrigger plugin="github">
<spec></spec>
</com.cloudbees.jenkins.GitHubPushTrigger>
</triggers>
<builders>
<hudson.tasks.Shell>
<command>set -euo pipefail
@ -115,9 +120,27 @@ fi
# Remove trailing slash if present in JENKINS_URL
JENKINS_URL="${JENKINS_URL%/}"
CURL_CONFIG="$(mktemp)"
chmod 600 "$CURL_CONFIG"
trap 'rm -f "$CURL_CONFIG"' EXIT
curl_config_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '%s' "$value"
}
{
printf 'user = "'
curl_config_escape "$JENKINS_USER"
printf ':'
curl_config_escape "$JENKINS_TOKEN"
printf '"\n'
} >"$CURL_CONFIG"
# 1. Fetch Crumb
CRUMB_HEADER=""
CRUMB_RESPONSE=$(curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" "${JENKINS_URL}/crumbIssuer/api/json" || true)
CRUMB_RESPONSE=$(curl -s --config "$CURL_CONFIG" "${JENKINS_URL}/crumbIssuer/api/json" || true)
if [ -n "$CRUMB_RESPONSE" ] && echo "$CRUMB_RESPONSE" | grep -q "crumb"; then
CRUMB_FIELD=$(echo "$CRUMB_RESPONSE" | python3 -c "import json, sys; print(json.load(sys.stdin)['crumbRequestField'])")
@ -127,7 +150,7 @@ fi
# 2. Check if job exists
CHECK_URL="${JENKINS_URL}/job/appsok-macos-certified/config.xml"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$JENKINS_USER:$JENKINS_TOKEN" "$CHECK_URL" || echo "500")
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --config "$CURL_CONFIG" "$CHECK_URL" || echo "500")
CURL_OPTS=(-s -w "%{http_code}" -o /dev/null)
if [ -n "$CRUMB_HEADER" ]; then
@ -137,7 +160,7 @@ fi
if [ "$HTTP_STATUS" = "200" ]; then
echo "Updating existing job appsok-macos-certified..."
UPDATE_URL="${JENKINS_URL}/job/appsok-macos-certified/config.xml"
STATUS=$(curl "${CURL_OPTS[@]}" -u "$JENKINS_USER:$JENKINS_TOKEN" -H "Content-Type: application/xml" --data-binary "$XML_CONTENT" "$UPDATE_URL" || echo "500")
STATUS=$(curl "${CURL_OPTS[@]}" --config "$CURL_CONFIG" -H "Content-Type: application/xml" --data-binary "$XML_CONTENT" "$UPDATE_URL" || echo "500")
if [ "$STATUS" = "200" ] || [ "$STATUS" = "201" ] || [ "$STATUS" = "302" ]; then
echo "Successfully updated job configuration."
else
@ -147,7 +170,7 @@ if [ "$HTTP_STATUS" = "200" ]; then
elif [ "$HTTP_STATUS" = "404" ]; then
echo "Creating new job appsok-macos-certified..."
CREATE_URL="${JENKINS_URL}/createItem?name=appsok-macos-certified"
STATUS=$(curl "${CURL_OPTS[@]}" -u "$JENKINS_USER:$JENKINS_TOKEN" -H "Content-Type: application/xml" --data-binary "$XML_CONTENT" "$CREATE_URL" || echo "500")
STATUS=$(curl "${CURL_OPTS[@]}" --config "$CURL_CONFIG" -H "Content-Type: application/xml" --data-binary "$XML_CONTENT" "$CREATE_URL" || echo "500")
if [ "$STATUS" = "200" ] || [ "$STATUS" = "201" ] || [ "$STATUS" = "302" ]; then
echo "Successfully created job."
else

View file

@ -28,11 +28,23 @@ void main() {
expect(script, contains('xcrun notarytool history --keychain-profile'));
expect(script, contains('prompt_plain "Jenkins URL" JENKINS_URL'));
expect(script, contains('prompt_plain "Jenkins username" JENKINS_USERNAME'));
expect(script, contains('prompt_plain "Jenkins API token" JENKINS_API_TOKEN'));
expect(
script,
contains('prompt_plain "Jenkins username" JENKINS_USERNAME'),
);
expect(
script,
contains('prompt_plain "Jenkins API token" JENKINS_API_TOKEN'),
);
expect(script, contains('"jenkins_url": os.environ["JENKINS_URL"]'));
expect(script, contains('"jenkins_username": os.environ["JENKINS_USERNAME"]'));
expect(script, contains('"jenkins_api_token": os.environ["JENKINS_API_TOKEN"]'));
expect(
script,
contains('"jenkins_username": os.environ["JENKINS_USERNAME"]'),
);
expect(
script,
contains('"jenkins_api_token": os.environ["JENKINS_API_TOKEN"]'),
);
});
test('certified build keeps Flutter gates before release packaging', () {
@ -123,6 +135,9 @@ void main() {
expect(script, contains('appsok-macos-certified'));
expect(script, contains('macbook-ultra'));
expect(script, contains('*/main'));
expect(script, contains('<triggers>'));
expect(script, contains('com.cloudbees.jenkins.GitHubPushTrigger'));
expect(script, isNot(contains('hudson.triggers.SCMTrigger')));
expect(script, contains('./scripts/build-certified-macos.sh'));
expect(
script,
@ -130,7 +145,9 @@ void main() {
);
expect(
script,
contains('build/macos/Build/Products/Release/AppSok-certified.zip.sha256'),
contains(
'build/macos/Build/Products/Release/AppSok-certified.zip.sha256',
),
);
expect(script, contains('<numToKeep>10</numToKeep>'));
expect(script, contains('<artifactNumToKeep>10</artifactNumToKeep>'));
@ -142,12 +159,24 @@ void main() {
expect(script, contains('sops -d'));
expect(script, isNot(contains('jenkins_api_token=')));
expect(script, isNot(contains(r'-u "$JENKINS_USER:$JENKINS_TOKEN"')));
expect(
script,
isNot(contains(r'python3 - "$JENKINS_USER" "$JENKINS_TOKEN"')),
);
expect(script, contains(r'CURL_CONFIG="$(mktemp)"'));
expect(script, contains(r'--config "$CURL_CONFIG"'));
expect(script, contains("trap 'rm -f \"\$CURL_CONFIG\"' EXIT"));
expect(script, contains('APPLY=false'));
expect(script, contains('DRY_RUN=true'));
expect(script, contains('--apply'));
expect(script, contains('--dry-run'));
for (final key in ['jenkins_url', 'jenkins_username', 'jenkins_api_token']) {
for (final key in [
'jenkins_url',
'jenkins_username',
'jenkins_api_token',
]) {
expect(script, contains('read_secret'));
expect(script, contains(key));
expect(setupScript, contains('"$key": os.environ'));