본문으로 건너뛰기

resumate 12. CI 복구와 배포 채널 개설

·15 min read·12 / 17

CI 실행 기록에 초록 체크가 하나도 없었다. 고치는 김에 배포 채널까지 열었다.

CI가 한 번도 성공한 적이 없었다

워크플로를 만든 게 8월 4일인데, 오늘 실행 기록을 열어보니 3건 전부 같은 자리에서 죽어 있었다.

Error: Multiple versions of pnpm specified

package.jsonpackageManager 필드는 3월 31일부터 있었고 워크플로는 8월 4일에 추가됐다. 처음부터 충돌이었던 것이다. 액션은 두 곳에 적힌 버전이 서로 다르면 어느 쪽이 맞는지 판단하지 않고 그냥 거부한다.

packageManager를 단일 출처로 두고 워크플로의 with.version을 뺐다. 그리고 실행하니 다음 실패가 나왔다.

error: Cannot find package 'pdf-lib' from 'sidecar/pdf.ts'

사이드카(Tauri가 앱과 함께 번들해 실행하는 별도 바이너리 서버)는 자체 package.jsonbun.lock을 갖는다. 사이드카 테스트는 bun(TypeScript를 그대로 실행하는 런타임 겸 테스트 러너)으로 돌린다. 루트 pnpm install로는 pdf-libhono도 깔리지 않아 bun test 4개가 모듈을 못 찾았다. 로컬에서는 sidecar/node_modules가 남아 있어서 보이지 않던 문제다. 세 워크플로 모두에 사이드카 설치를 넣었다.

그 다음.

error: PDF 변환기(html2pdf)를 찾을 수 없습니다.

exports.test.ts는 파일명만 검증하는 테스트인데 buildExport가 실제로 PDF를 만든다. Swift 헬퍼 바이너리가 있어야 한다. 그런데 build-sidecar.sh를 그대로 부르면 181MB짜리 whisper 음성 인식 모델까지 요구한다. 테스트를 돌리자고 모델을 내려받을 이유는 없어서, Swift 헬퍼 빌드만 build-helpers.sh로 떼어내고 build-sidecar.sh가 그걸 부르게 했다. 산출물은 이전과 같다.

그리고 네 번째.

resource path `binaries/sidecar` doesn't exist

cargo test도 Tauri의 빌드 스크립트(컴파일 전에 실행되는 Rust 사전 준비 코드)를 거치고, 그 스크립트는 tauri.conf.json에 선언된 번들 리소스가 실제로 있는지 확인한다. Rust 테스트에도 결국 사이드카 바이너리와 whisper 모델까지 필요했다. 헬퍼만 만드는 스텝을 build:sidecar로 바꾸고, test.ymlrelease.yml과 같은 모델 캐시·검증 스텝을 갖게 했다. 캐시 키가 같아서 두 워크플로가 같은 캐시를 공유한다.

로컬에서 cargo test가 2개 통과하는 것을 확인했다.

네 번의 실패가 전부 "내 기계에는 있는데 CI에는 없는 것"이었다. sidecar/node_modules, Swift 헬퍼, whisper 모델. 로컬은 몇 달치 부산물이 쌓인 환경이라 무엇에 의존하고 있는지 알 수가 없다. CI는 그걸 매번 처음부터 세워보는 장치인데, 정작 그 장치가 한 번도 안 돌고 있었다.

서명 인증서가 없어도 배포는 시작한다

Apple 서명·공증 인증서가 아직 없다. 정식 경로(publish.yml)를 켤 수 없다.

그렇다고 배포를 미룰 이유는 없다고 봤다. 로컬 미리보기에 쓰던 ad-hoc 서명(개발자 인증서 없이 바이너리에 임시 서명만 붙이는 방식) 빌드를 그대로 CI에 올려서 채널을 먼저 열었다. main에 push하면 버전이 오르고 DMG가 만들어져 공개 릴리스 레포로 나간다.

flowchart LR
    T["토큰 확인"] --> B["테스트·빌드"]
    B --> D["DMG 생성"]
    D --> R["공개 레포 릴리스"]
    R --> V["버전 커밋 push"]

순서를 정하는 데 걸린 게 하나 있다. 테스트·빌드가 먼저고 버전 커밋 push는 마지막이다. 반대로 하면 빌드가 깨진 커밋에 버전이 박히고, 다음 릴리스는 그 번호를 건너뛰게 된다.

release.yml 맨 앞에는 RELEASE_REPO_TOKEN 확인 스텝을 뒀다. 토큰이 없는 채로 진행하면 15분짜리 빌드를 다 돌고 마지막 스텝에서 실패하는데, 그때는 버전 커밋과 태그가 이미 main에 올라가 있다. 버전 번호만 소모한다.

bump-version.shpackage.json·tauri.conf.json·Cargo.toml·Cargo.lock을 함께 올린다. 하나라도 어긋나면 DMG 파일명과 릴리스 태그가 서로 다른 버전을 가리킨다.

publish.ymlapp-v* 태그 트리거는 workflow_dispatch(수동 실행만 허용하는 트리거)로 잠갔다. 준비 없이 태그가 달리면 조용히 실패하니, 켤 때 필요한 것들을 주석으로 남겨뒀다.

v0.2.2와 v0.2.3이 이 경로로 나갔다. 지금 나가는 DMG는 ad-hoc 서명이라 사용자가 Gatekeeper(서명·공증을 확인해 미확인 앱 실행을 막는 macOS 검사)를 우회해야 열린다. 인증서를 받으면 publish.yml 정식 경로를 켠다.

릴리스 노트를 사람이 읽는 말로

배포 레포에는 머지된 PR이 없다. DMG만 올라가는 곳이라, 머지된 PR 목록으로 노트를 만드는 gh--generate-notes가 아무것도 못 만든다.

그래서 소스 레포의 release-v* 태그 사이 커밋을 직접 읽어 feat/fix로 묶게 했다. chore·docs·test·ci·style은 넣지 않는다 — 앱을 쓰는 사람 입장에서 달라지는 게 없다. conventional commits(feat:, fix: 같은 접두사로 종류를 표시하는 커밋 규약) 형식이 아닌 제목은 버리지 않고 "그 밖에"로 넣어 누락되지 않게 했다.

노트를 뽑는 스텝은 태그 생성보다 앞에 둬야 한다. 뒤에 두면 방금 만든 태그가 직전 태그가 되어 범위가 통째로 비어버린다.

범위 끝점도 인자로 받게 고쳤다. HEAD 고정이면 과거 릴리스 노트를 다시 만들 때 그 시점으로 체크아웃해야 하는데, 지난 버전 노트를 특정 구간으로 채우면서 바로 필요해졌다.

남은 문제는 문체였다. 커밋은 "~한다"로 쓰는데 그게 그대로 나가면 사용자용 노트로는 어색하다. 어미만 존댓말로 바꾸고 스코프 라벨(file-tree 등)은 내부 구조라 떼어냈다.

커밋에 쓰는 현재 평서형이면 어미만 보고 바꿀 수 있다.

입력 형태변환
"~는다"습니다 (넣는다 → 넣습니다)
어미 "-ㄴ다" + 니다 (한다 → 합니다, 만든다 → 만듭니다, 모은다 → 모읍니다)
규칙에 안 걸림그대로 둔다

억지로 바꾸면 더 나쁘다. 그래서 규칙에 안 걸리면 손대지 않았다. 한글 음절 연산이 필요해서 bash를 버리고 node로 옮겼다.

poppler가 없으면 이름이 틀어진다

PDF에서 텍스트를 뽑는 체인이 pdftotext → OCR 두 단계였다. pdftotext(poppler라는 PDF 도구 모음에 들어 있는 텍스트 추출 명령)가 없으면 곧장 OCR(이미지에서 글자를 인식하는 방식)로 내려간다.

텍스트가 멀쩡히 박힌 PDF까지 글자를 그림에서 추측했다. 실측에서 "홍길동 이력서"가 이렇게 나왔다.

홍길동..이력서

이력서는 이름과 수치가 틀리면 안 되는 문서다.

poppler를 번들할 수도 있었지만, 이미 들어 있는 mupdf(PDF 렌더링과 텍스트 추출을 하는 라이브러리, wasm으로 컴파일된 것)를 2순위로 세우는 쪽을 택했다. 첨삭(pdfedit.ts)이 쓰는 바로 그 wasm이라 새 의존성도, 새 라이선스 노출도 없다.

flowchart LR
    P["pdftotext"] -->|"poppler 없음"| M["mupdf wasm"]
    M -->|"그림뿐인 PDF"| O["OCR"]

pdftotext를 1순위로 두는 이유는 그대로다. -layout이 단·열 구조를 공백으로 살려주고, 첨삭이 같은 좌표계(-bbox-layout)를 쓴다. OCR은 그림뿐인 PDF의 마지막 수단으로 남는다.

wasm 주입은 mupdfLoad.ts 하나로 모았다. 주입 방식이 갈리면 한쪽만 고쳐두고 다른 쪽이 패키징에서 조용히 죽는다.

poppler를 못 찾게 막고 재빌드해서 확인했다.

입력이전이후
텍스트 PDF503method: "mupdf", 글자 정확
이미지 PDF503method: "ocr"

지원 확장자가 화면마다 달랐다

accept 속성, 안내 문구, 파서 매핑이 화면마다 따로 하드코딩돼 있었다. 형식을 하나 추가하면 세 곳이 어긋났다.

fileTypes.ts가 목록과 파서 연결을 모두 소유하게 했다. 그리고 한 가지를 못 박았다 — 전용 파서가 없는 확장자는 텍스트로 오인하지 않고 즉시 거절한다. docx나 zip을 TextDecoder로 읽으면 깨진 바이트가 그대로 이력서 본문으로 들어간다.

캐릭터 뷰 네 개와 이력서 선택기도 이 정책에서 accept와 안내 문구를 가져오게 바꿨다. 이력서 분석가 Nyx는 이력서 형식이 아닌 파일을 첨부 시점에 막는다. 분석까지 다 간 뒤에 실패하지 않게.

FileTree도 같은 병이었다. 자체 UPLOADABLE_EXTENSIONS가 실제 파서가 없는 doc·docx·zip을 허용해서 첨부 후에야 실패했다. isParseableFile로 바꿔 화면과 추출기가 같은 목록을 본다.

PPTX는 readFile로 읽으면 바이너리가 그대로 나와서, PDF처럼 base64로 읽어 텍스트를 뽑아 보여주게 했다.

sticky 헤더 위로 필드가 비쳤다

이력서 빌더에서 스크롤을 내리면 고정된 섹션 헤더 위쪽 띠로 아래 필드가 비쳐 지나갔다.

sticky는 두 가지에 동시에 묶인다. top의 기준은 스크롤포트(스크롤 컨테이너의 패딩 박스)다. 그런데 sticky 박스는 자기 컨테이닝 블록인 스크롤 박스의 콘텐츠 박스를 벗어나지 못한다. 스크롤 박스에 py를 주면 헤더가 그 패딩만큼 아래에 고정되고, 그 위로 생긴 띠는 아무것도 가리지 않는다.

세로 여백을 스크롤 박스에서 안쪽 래퍼로 옮겼다.

두 곳에 적힌 값은 틀리는 게 아니라 갈라진다

오늘 고친 것 중 절반이 "한 가지를 두 곳에 적어둔 것"이었다. pnpm 버전이 packageManager와 워크플로 양쪽에, 지원 확장자가 화면마다, wasm 주입이 두 방식으로. 셋 다 증상은 달랐지만 고친 방법은 같았다 — 소유자를 하나 정하고 나머지는 거기서 읽어가게.

두 곳에 적힌 값은 어느 쪽도 틀리지 않았는데 합쳐놓으면 안 맞는다. pnpm 액션이 "둘 중 뭐가 맞는지 모르겠다"며 거부한 게 정확한 반응이었다.

CI가 세 번 다 실패해 있었다는 걸 오늘까지 몰랐던 건 좀 뜨끔했다. 초록 체크가 안 뜨는 걸 눈으로 보면서도 "나중에" 하고 넘겼다. 안 도는 CI는 없는 CI보다 나쁘다 — 없으면 로컬에서라도 확인하는데, 있으면 확인했다고 착각한다.