CI/CD 파이프라인은 변경 사항이 git push에서 프로덕션까지 가는 자동화된 경로입니다. 지속적 통합(CI)은 모든 push를 깨끗한 머신에서 빌드하고 테스트해서, 실수가 생긴 지 몇 분 만에 드러나게 합니다. 지속적 전달 또는 배포(CD)는 통과한 것을 반복 가능한 단계 하나로 릴리스합니다. 사람이 버튼을 누르면 전달(delivery), 아무도 누르지 않으면 배포(deployment)입니다. GitHub Actions에서 파이프라인은 .github/workflows/ 아래의 YAML 파일이고, 언제 무엇을 실행할지 적어 둡니다. 제 파이프라인은 일부러 얇게 만들었습니다. 체크는 제 노트북에서 돌리는 명령을 Docker Compose 파일이 띄우는 것과 같은 Postgres·Valkey 이미지에 대고 그대로 돌리고, 배포는 전부 GitHub 없이도 똑같이 동작할 스크립트나 CLI입니다.
2024년에 플랫폼 종속 YAML 대신 Docker Compose로 CI/CD 하기라는 글을 썼습니다. 빌드 로직은 Compose에 두고 CI 도구는 그걸 호출하는 일만 하게 하라는 내용이었죠. 2년이 지나 제가 실제로 배포하는 저장소를 전부 다시 훑어봤습니다. 워크플로가 있는 저장소는 넷이고, 커미터는 모두 저 한 명입니다. 그리고 그 워크플로 중 docker compose를 호출하는 건 이제 하나도 없습니다. 원칙은 살아남았고, 방법은 살아남지 못했습니다. 아래는 그 자리를 대신한 것들입니다. 줄여서 예제로 만든 파일, GitHub가 기록한 단계별 소요 시간, 그리고 CI/CD 다이어그램이 바라는 것보다 덜 자동화된 부분까지 적었습니다.
제가 돌리는 파이프라인 전부, 표 하나로#
| 파이프라인 | 시작하는 쪽 | CI가 확인하는 것 | 가는 곳 | 릴리스하는 사람 |
|---|---|---|---|---|
| 이 블로그와 클라이언트의 Hugo 사이트 하나 | 모든 push, 브랜치 무관 | hugo --gc 빌드가 되는지 | Cloudflare Pages: main은 프로덕션, 다른 브랜치는 미리보기 URL | 없음. push가 곧 릴리스 |
| 제 마켓플레이스 앱의 API | 배포 스크립트, 수동 | main에 push할 때마다 린트, 타입 체크, Django 테스트 | Fly.io, 롤링 배포 | 저 |
| 같은 앱의 프런트엔드: 웹, OTA 업데이트, 스토어 빌드 | 릴리스 스크립트와 로컬 스토어 빌드, 수동 | main에 push할 때마다 TypeScript와 Jest | 오브젝트 스토리지, OTA 업데이트 서비스, 두 앱 스토어 | 저 |
| 제 오픈소스 Python 패키지 pyhtml2text | 모든 push, 풀 리퀘스트, 태그 | 빌드 타깃 15개용 wheel, 대부분에서 테스트 | PyPI, 태그를 달 때 | 태그 |
교과서 용어로 말하면 두 Hugo 사이트는 지속적 배포이고, 앱(제가 만드는 마켓플레이스 겸 커뮤니티 앱)은 지속적 통합에 명령 한 줄짜리 릴리스를 더한 지속적 전달입니다. 체크는 뭔가 잘못됐다고 알려 줄 뿐, 배포를 막지는 않습니다. 일부러 이렇게 나눴습니다. OTA 업데이트는 대상 바이너리 버전을 쓰는 모든 휴대폰에 닿으니, 머지의 부작용이 아니라 결정이어야 합니다. 그리고 API는 데이터베이스 마이그레이션이 실행되는 곳입니다. 제 나머지 Django 앱 두 개에는 워크플로가 아예 없습니다.
CI 쪽: 워크플로 하나, 잡 두 개#
앱의 CI는 .github/workflows/checks.yml 파일 하나입니다. 10월 11일 전까지는 이것조차 없었습니다. 아래는 그 구조를 줄여서 만든 예제입니다.
# Lint, types and tests on every push and pull request to main.
# Nothing here deploys.
name: Checks
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: myapp
POSTGRES_DB: myapp
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U myapp"
--health-interval 5s --health-timeout 5s --health-retries 20
valkey:
image: valkey/valkey:8-alpine
ports: ["6379:6379"]
env:
DEBUG: "True"
DATABASE_URL: postgresql://myapp:myapp@localhost:5432/myapp
REDIS_URL: redis://localhost:6379
PYTHON_JIT: "0" # match production
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run mypy myapp/
- run: uv run python manage.py test --parallel 4 --noinput
app:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
# Expo writes this on `expo start` and it is gitignored,
# so a fresh checkout doesn't have it.
- run: printf '/// <reference types="expo/types" />\n' > expo-env.d.ts
- run: npm run typecheck
- run: npx jest --ci두 잡은 나란히 돕니다. 지금까지 성공한 35번의 실행에서 앱 잡의 중앙값은 7분쯤, Python 잡은 12분 반쯤입니다.
services: 블록이 바로 Compose 파일이 옮겨 간 자리인데, 다음 절에서 다룹니다. 환경 변수는 몇 개와 플래그 하나뿐입니다. 나머지는 전부 개발자가 docker compose up에서 받는 개발용 기본값으로 떨어지기 때문에, 테스트에는 시크릿이 하나도 필요 없습니다. 정말 그런지 보려고 딱 그 환경만, 다른 건 아무것도 없이 테스트 일부를 돌려 확인했습니다.
CI는 제 노트북, 프로덕션 이미지와 같은 인터프리터, 즉 uv가 직접 빌드한 Python 3.15.0으로 돕니다. uv sync가 .python-version을 읽고 정확히 그 빌드를 내려받기 때문입니다. (실제 파일에서는 setup-uv에 python-version-file 입력도 넘기는데, 이건 아무 일도 하지 않습니다. setup-uv v6에는 그런 입력이 없고, 매 실행마다 그렇다는 경고가 붙어 있습니다. 이 글을 쓰다가야 알았습니다.) 인터프리터를 맞춘 보람은 워크플로를 넣은 지 8일 만에 나타났습니다. 10월 19일에 3.15의 JIT 컴파일러를 켰더니, 켠 뒤 첫 실행에서 테스트 하나가 실패했습니다. 앱이 저장할 때마다 지나가는 코드 경로의 평범한 yield from이 AttributeError: 'list' object has no attribute 'send'를 던진 겁니다. 같은 커밋을 JIT를 끄고 돌리면 통과했습니다. 제 Mac은 arm64라서 이 문제를 한 번도 만나지 않았고, 러너는 프로덕션 머신과 같은 x86_64입니다. JIT는 CI에서도 Dockerfile에서도 다시 꺼 두었고, 버그는 프로덕션에 닿지 않았습니다.
사실 맨 첫 실행도 실패했는데, 이유는 더 시시했습니다. Expo는 expo start를 할 때 expo-env.d.ts를 만들고 이 파일은 gitignore되어 있습니다. 그래서 제가 쓰던 작업 사본에는 모두 있었고, 새로 받은 체크아웃에는 없었죠. TypeScript가 로컬에서는 한 번도 보지 못한 오류 다섯 개를 냈습니다. 그 파일을 써 주는 단계가 해결책입니다.
concurrency는 같은 브랜치에 더 새로운 push가 오면 이전 실행을 취소합니다. 지금까지 취소된 12번의 실행은 모두 이렇게 끝났습니다. 커밋 전 훅(pre-commit hook)이 린터를 돌리고, Python이나 TypeScript가 스테이징돼 있으면 타입 체커도 돌리기 때문에 실수는 대부분 push까지 가지도 않습니다. 이 파일을 가져다 쓴다면 액션은 새 메이저 버전으로 올리세요. actions/checkout@v4, actions/setup-node@v4, astral-sh/setup-uv@v6가 지원 종료된 Node.js 20을 대상으로 한다는 경고가 매 실행마다 뜹니다.
Docker Compose 파일은 어디로 갔나#
앱의 docker-compose.yml에서 CI와 관련된 부분을 예제로 옮긴 것입니다. 실제 파일에는 서비스도 더 있고 환경 변수도 훨씬 많습니다.
services:
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
# Host port 55432, not 5432, so it can't collide with a local Postgres.
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD=myapp
- POSTGRES_DB=myapp
ports:
- "55432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 5s
retries: 10
api:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp
- REDIS_URL=redis://valkey:6379
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_started
volumes:
postgres_data:checks.yml의 services: 블록 옆에 놓고 보면 같은 이미지 두 개입니다. Postgres 17(여기서는 Alpine 빌드, CI에서는 Debian 빌드)과 valkey/valkey:8-alpine. 지금의 관계는 이렇습니다. Compose 파일은 개발자가 돌리는 것이고, CI는 같은 이미지를 서비스 컨테이너로 선언한 다음 개발자가 돌리는 것과 같은 명령, uv run …과 npm run …을 러너에서 바로 돌립니다.
2024년 글과 비교하면, 제가 가장 신경 쓰던 부분은 살아남았습니다. CI 도구는 얇고, 체크와 배포는 제가 직접 돌릴 수 있는 명령입니다. 살아남지 못한 건 CI가 Compose를 호출하는 방식입니다. 앱의 CI는 Docker 이미지를 아예 빌드하지 않습니다. 이미지는 배포할 때 Fly의 빌더가 한 번 빌드하고, CI는 코드가 괜찮은지만 말해 주면 됩니다. 툴체인이 캐시돼 있으면 그건 6초짜리 설치로 끝납니다. 게다가 Compose 파일은 작업 사본을 바인드 마운트하고 데이터를 시드하는데, 테스트 실행이 할 일은 아닙니다. 그러니 CI용 Compose 파일이 따로 필요했을 테고, 그건 같은 서비스를 두 번째로 정의해 손으로 맞춰 가야 한다는 뜻입니다.
그리고 2024년 예제에는 당시 눈치채지 못한 버그가 있었습니다. docker compose build는 이미지를 빌드할 뿐 서비스의 command를 실행하지 않습니다. 그러니 그 파일의 npm test는 CI에서 한 번도 돌지 않았고, 워크플로는 테스트가 통과하든 말든 초록불이었습니다.
그 글이 가장 걱정했던 위험, 즉 CI 업체를 바꾸는 일은 결국 일어나지 않았습니다. 실제로 일어난 이동은 반대쪽 끝에서였습니다. 이 블로그와 그 클라이언트 사이트가 호스팅을 Cloudflare Pages로 옮겼는데, 블로그 쪽은 45줄짜리 워크플로 하나를 새로 쓰는 걸로 끝났습니다. 빌드가 원래 명령 한 줄이었기 때문이죠. 그리고 제가 경고했던 300줄짜리 워크플로요? 하나 있습니다. pyhtml2text의 워크플로입니다. wheel을 Linux, macOS, Windows 러너에서 각각 여러 아키텍처로 빌드해야 해서 긴 건데, 그래서 플랫폼 전용 YAML이 맞는 도구인 유일한 작업이기도 합니다.
캐싱: 단계별 소요 시간부터 읽기#
GitHub는 모든 단계의 소요 시간을 기록해 두고, gh api repos/<owner>/<repo>/actions/runs/<run-id>/jobs로 꺼내 볼 수 있습니다. 아래는 10월 20일, 앱의 가장 최근 성공 실행입니다.
| 잡 | 단계 | 초 |
|---|---|---|
| Python | Postgres와 Valkey 시작 | 18 |
| Python | uv sync --locked, 캐시 있음 | 6 |
| Python | mypy | 27 |
| Python | Django 테스트 | 761 |
| App | npm ci, 캐시 있음 | 60 |
| App | Jest | 333 |
의존성 캐시 두 개는 모두 켜져 있고, 둘 다 저렴한 부분입니다. GitHub가 이 저장소용으로 보관하는 uv 캐시는 200KB 정도인데, setup-uv가 다시 내려받으면 되는 것들은 정리해 버리기 때문입니다. npm 캐시는 400MB에 가깝습니다. 시간이 드는 곳은 테스트이고, 테스트는 이미 4갈래로 병렬 실행됩니다. 실행을 더 빠르게 하고 싶다면 캐시가 아니라 테스트 스위트를 손보겠습니다.
블로그는 정반대입니다. Hugo가 처리한 이미지는 gitignore된 resources/_gen에 있어서, 새 러너마다 커버, 본문 이미지, 플레이스홀더를 전부 다시 인코딩했습니다. 아래 워크플로는 actions/cache로 이것을 복원합니다. 키는 사진 자체의 해시이고, 키가 맞지 않으면 가장 최근의 이전 캐시로 대신합니다. Hugo는 빠진 것만 처리하니까요. 캐시를 넣기 전 마지막 8번의 실행에서 빌드 단계는 3376초 걸렸고, 넣은 뒤 26번은 620초였습니다. 94초가 한 번 있었을 뿐이고, 같은 커밋에서 플레이스홀더와 두 번째 카드 크기까지 추가했는데도 그렇습니다.
앱의 이미지는 Fly의 빌더에서 빌드되고, 의존성 파일을 코드보다 먼저 복사하며 uv의 다운로드 캐시는 BuildKit 캐시 마운트(RUN --mount=type=cache,target=/root/.cache/uv)에 둡니다. 하지만 더 큰 개선은 캐시가 아니었습니다. 예전 .dockerignore는 블록리스트였는데, 추가 git worktree들을 모아 둔 폴더를 빠뜨렸습니다. worktree마다 자기 의존성과 빌드 산출물이 들어 있었죠. 그래서 파일 27만 7천 개, 7.7GB가 배포할 때마다 빌더로 올라갔습니다. 허용 목록으로 다시 쓴 지금은 빌드 컨텍스트가 40MB 정도입니다. 그 패턴을 예제로 보면 이렇습니다.
# Exclude everything, then let back in only what the Dockerfile COPYs.
*
!pyproject.toml
!uv.lock
!manage.py
!myapp
!static
# Rules match the whole relative path and the last match wins,
# so these re-excludes apply inside the allowed directories too.
**/__pycache__/
**/*.pyc
**/.DS_Store
**/*.log시크릿은 최대한 적은 곳에#
시크릿은 세 군데에 있습니다. GitHub Actions 시크릿은 워크플로가 하는 일을 위한 것이고, 두 Hugo 저장소에는 CLOUDFLARE_API_TOKEN 딱 하나씩만 있습니다. Fly 시크릿은 앱의 런타임 설정으로 flyctl secrets set으로 넣고, Actions를 거치지 않습니다. 그리고 제 노트북의 gitignore된 .env에는 배포 스크립트가 Cloudflare 캐시를 비울 때 쓰는 토큰이 있습니다.
PyPI에는 시크릿이 아예 필요 없습니다. pyhtml2text는 신뢰할 수 있는 게시(trusted publishing)를 씁니다. PyPI가 GitHub이 워크플로에 발급하는 수명 짧은 OIDC 토큰을 받아 주기 때문에, 릴리스 잡은 id-token: write만 요청하고 저장소의 시크릿 페이지는 비어 있습니다. 샐 것도, 교체할 것도 없죠.
토큰에는 필요한 만큼만 줍니다. GitHub에 있는 앱의 Fly 토큰은 머신 목록만 조회하는 예약 체크용이라 fly tokens create readonly로 만들었습니다. Pages 배포는 contents: read와 deployments: write만 요청하고 그 이상은 없습니다.
입력값은 스크립트에 바로 넣지 않고 반드시 env:를 거칩니다. OTA 워크플로는 자유롭게 쓰는 릴리스 노트를 받습니다. 이걸 run: 안에 ${{ inputs.notes }}로 쓰면 bash가 읽기 전에 텍스트가 스크립트에 그대로 붙여 넣어지니, 따옴표와 세미콜론이 든 노트는 코드가 되어 버립니다. 환경 변수로 넘기면 데이터로 남습니다. 패턴을 예제로 보면 이렇습니다.
- name: Publish
env:
OTA_API_TOKEN: ${{ secrets.OTA_API_TOKEN }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ROLLOUT: ${{ inputs.rollout }}
INPUT_NOTES: ${{ inputs.notes }}
run: |
args=("$INPUT_PLATFORM")
[ -n "$INPUT_ROLLOUT" ] && args+=(--rollout "$INPUT_ROLLOUT")
[ -n "$INPUT_NOTES" ] && args+=(--notes "$INPUT_NOTES")
./scripts/publish-ota.sh "${args[@]}"아예 GitHub 밖에 두는 키도 있습니다. 예약 리포트 하나에는 앱의 로그인 백엔드에 대한 관리자 권한이 있는 키가 필요해서, 그 리포트는 예약 워크플로에서 제 Mac의 예약 작업으로 옮겼습니다. 워크플로는 수동 예비용으로 남아 있고, 그 키가 필요한 섹션은 빼고 게시합니다.
배포: push마다, 스크립트로, 태그로#
Hugo 사이트는 push마다 배포됩니다#
블로그 워크플로를 예제로 옮긴 것입니다. Pages 프로젝트를 처음 만드는 단계는 뺐습니다.
name: Deploy to Cloudflare Pages
on:
push:
workflow_dispatch:
env:
HUGO_VERSION: "0.165.0"
PAGES_PROJECT: my-site
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
# Processed images are gitignored; restore them so Hugo only
# re-encodes what is new. An older cache still helps.
- uses: actions/cache@v4
with:
path: resources/_gen
key: hugo-resources-${{ hashFiles('content/**/*.jpg', 'content/**/*.png', 'assets/img/**') }}
restore-keys: |
hugo-resources-
# A multilingual site has no root 404.html; without one, Pages
# answers unknown paths with index.html and a 200.
- name: Build
run: |
hugo --gc
cp public/en/404.html public/404.html
# ...
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}--branch=${{ github.ref_name }} 덕분에 main은 프로덕션이 되고 다른 브랜치는 모두 미리보기 URL을 받습니다. 클라이언트 사이트의 워크플로는 이것과 같은 구조에 빌드 전 rm -rf public 한 줄이 더 있습니다. 그쪽은 public/이 커밋되어 있어서, 안 그러면 삭제한 페이지가 업로드에 계속 남기 때문입니다.
Hugo 버전은 여기에서만 고정합니다. 10월에 패키지 매니저 업그레이드로 제 노트북의 Hugo가 0.168이 됐는데, 테마가 지원하는 최신 버전보다 하나 높습니다. 배포는 0.165에 머물렀고 아무 일도 없었습니다.
그리고 빌드가 곧 테스트입니다. 9월 14일, 한 브랜치의 미리보기 배포가 세 번 연달아 실패했습니다. 그 브랜치의 글 하나가 Hugo의 ref로 아직 초안인 글을 가리키고 있었거든요. CI는 프로덕션이 빌드하는 것과 똑같이, 초안을 빼고 빌드합니다. 그러니 링크는 아무것도 가리키지 않았고 Hugo는 REF_NOT_FOUND로 멈췄습니다. 그동안에도 main은 계속 배포되고 있었습니다.
앱의 API는 스크립트로 배포합니다#
배포 스크립트의 핵심을 예제로 옮긴 것입니다.
IMAGE_LABEL="deploy-$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)"
# Build first, then wake a spare machine, then roll out exactly that image.
flyctl deploy --config fly.toml --build-only --push --image-label "$IMAGE_LABEL"
curl -fsS --retry 5 https://myapp.fly.dev/health > /dev/null
flyctl deploy --config fly.toml --image "registry.fly.io/myapp:$IMAGE_LABEL"그리고 fly.toml에서 짝이 되는 부분입니다.
[deploy]
# max_unavailable only counts machines that are running,
# which is why the script wakes one first.
strategy = "rolling"
max_unavailable = 1이미지는 한 번 빌드해서 커밋과 시각으로 만든 라벨을 붙여 Fly 레지스트리에 올리고, 그 라벨로 배포합니다. 그래서 롤아웃되는 건 정확히 빌드한 그것입니다.
무중단 배포를 가능하게 하는 건 예비 머신을 먼저 깨우는 단계입니다. max_unavailable = 1은 실행 중인 머신만 셉니다. 그런데 앱은 한가할 때 실행 중인 머신을 하나도 두지 않습니다(일시 정지된 머신은 1초 남짓이면 깨어납니다). 그래서 깨어 있던 머신 하나가 잠든 머신들과 같은 묶음에서 교체되곤 했고, 그 뒤에는 따뜻한 머신이 하나도 없었습니다. 9월 28일에 오리진에 프로브를 계속 보내 봤더니, 그냥 flyctl deploy를 할 때는 타임아웃 7번과 7.3초의 공백이 있었고, 먼저 빌드하고 롤아웃 전에 머신 하나를 깨워 두는 스크립트 배포로는 요청 2,514개 중 실패가 하나도 없었습니다.
스크립트의 나머지는 오류 알림이 오는 팀 채팅 채널에 배포 표시를 남깁니다. 그래야 오류가 몰렸을 때 방금 무엇이 바뀌었는지와 맞춰 볼 수 있죠. 그리고 Cloudflare 캐시를 비우고 머신들을 데워 둡니다. 웹 프런트엔드는 이미지를 아예 거치지 않는데, 그건 API를 재배포하지 않고 웹 프론트엔드 배포하기에 적었습니다.
마이그레이션은 파이프라인 단계가 아닙니다. Fly의 release_command가 제 환경에서는 멈춰 버려서, 새 이미지로 처음 부팅하는 머신이 락을 잡고 마이그레이션하는 동안 나머지는 기다립니다. 그러니 모든 마이그레이션은 아직 돌고 있는 코드와도 맞아야 합니다. 컬럼은 한 배포에서 추가하고, 다음 배포에서 쓰지 않게 하고, 세 번째 배포에서 지웁니다.
이 스크립트에 꼭 제 노트북이어야 하는 부분은 없지만, 노트북에만 있는 파일 몇 개가 스크립트를 거기 묶어 둡니다. Cloudflare 자격 증명, 그리고 마지막으로 무엇을 배포했는지 적어 둔 기록입니다. 새 러너에는 이 파일들이 없죠. 커밋하지 않은 변경도 배포해 버리고 채팅 채널에 그렇다고 알리는데, 웹 릴리스 스크립트는 더러운 작업 트리를 거부합니다. 브랜치마다 환경 하나에서 저는 이걸 손으로 하는 건 혼자일 때는 괜찮지만, 두 번째 개발자부터는 git에서 뭔가 일어났기 때문에 일어나야 한다고 썼습니다. 앱의 개발자는 아직 한 명입니다. 그 글의 브랜치 환경 워크플로는 제가 만들었던 브랜치에 그대로 있습니다.
OTA 워크플로는 수동 전용입니다. 게시는 결정이어야 하고, 업데이트 서비스는 부분 롤아웃이 열려 있는 동안 새 릴리스를 거부하기 때문에 push 트리거라면 카나리 배포 때마다 걸릴 테니까요. 이 워크플로는 한 번도 실행된 적이 없습니다. 지금까지 OTA 업데이트는 전부 제 노트북에서, 워크플로가 호출하는 것과 같은 스크립트로 나갔습니다. 얇은 워크플로를 쓰는 이유가 한 줄로 이겁니다. 릴리스를 어떻게 시작하든 같은 스크립트죠.
pyhtml2text는 태그로 릴리스합니다#
릴리스 잡입니다. 패키지 이름은 일반적인 이름으로 바꿨습니다.
release:
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [linux, macos, windows, sdist]
environment:
name: pypi
url: https://pypi.org/p/mypackage
permissions:
id-token: write # trusted publishing and signing
contents: write
attestations: write
steps:
- uses: actions/download-artifact@v7
- uses: actions/attest@v4
with:
subject-path: 'wheels-*/*'
- uses: astral-sh/setup-uv@v7
if: ${{ startsWith(github.ref, 'refs/tags/') }}
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: uv publish --trusted-publishing always 'wheels-*/*'릴리스 잡은 모든 빌드 잡을 기다리고, 실제 게시는 태그일 때만 합니다. 수동 실행은 빌드하고 증명(attestation)까지 만들지만 아무것도 올리지 않습니다. actions/attest는 어느 워크플로가 각 wheel을 빌드했는지 기록하고, uv publish --trusted-publishing always는 비밀번호 대신 OIDC 토큰으로 업로드합니다. 패키지 자체에 대해서는 닫힌 풀 리퀘스트, 포크, 그리고 PyPI 패키지에 썼습니다.
예약 워크플로는 최선을 다할 뿐입니다#
앱에는 예약 워크플로가 하나 있습니다. Fly가 기준치보다 머신을 더 띄우지 않았는지 보는 체크로, 그냥 두면 청구서에서 처음 알게 될 일입니다. 알림은 일부러 실패하는 방식으로 보냅니다. 예약 실행이 실패하면 GitHub이 소유자에게 메일을 보내니까요. 설정은 cron: '*/15 * * * *', 하루 96번입니다. 그런데 지난 2주 동안은 하루 3~8번만 돌았고, 실행 간격의 중앙값은 3시간 40분쯤이었습니다. GitHub 문서가 내놓는 설명은 부하뿐입니다. Actions가 바쁘면 예약 실행이 늦어질 수 있고, 아주 바쁘면 빠질 수도 있다는 거죠. 계속되는 스케일 아웃이라면 몇 시간이면 충분합니다. 몇 분 안에 울려야 하는 것이라면 GitHub의 cron은 모니터링이 아닙니다.
파이프라인에 던져 볼 질문#
파이프라인을 만들고 있거나 이미 있는 걸 들여다보고 있다면:
- GitHub Actions가 하루 동안 멈추면 그래도 릴리스할 수 있나요? 못 한다면 릴리스 로직이 YAML 안에 있는 것이고, 2024년 글이 여전히 유효합니다.
- CI가 프로덕션과 같은 인터프리터, 같은 CPU 아키텍처, 같은 데이터베이스 메이저 버전으로 도나요? 제 CI가 컴파일러 버그를 잡은 건 러너와 프로덕션은 둘 다 x86_64이고 제 노트북은 아니었기 때문입니다.
- 새로 받은 체크아웃이 통과하나요? 안 된다면 대개 생성되고 gitignore된 파일 때문입니다.
- 실행 시간은 실제로 어디에 쓰이나요? 캐시를 넣기 전에 단계별 소요 시간부터 읽어 보세요.
- 시크릿마다 몇 군데에 있나요? 그리고 정말 GitHub에 있어야 하나요?
