블로그에 글을 올릴 때마다 수동으로 빌드하고 배포하는 건, 한두 번은 괜찮지만 반복되면 상당한 시간 낭비다. hugo build 치고, 결과물 확인하고, FTP나 rsync로 올리고, 혹시 잘못된 건 없나 확인하고… 이 루틴을 매번 반복하다 보면, 글 쓰는 시간보다 배포하는 시간이 더 길어지는 날이 온다.
이 블로그는 Astro로 만들어 Cloudflare Pages에 올린다. 그런데 배포는 git push 자동화가 아니라 wrangler로 직접 올리는 수동 방식이다. 사실 처음엔 GitHub Actions로 push할 때마다 자동 배포되게 붙이려고 했는데 — 결론부터 말하면 11번 연속 실패하고 결국 수동 배포로 돌아왔다. 그 실패담은 글 뒤쪽 트러블슈팅에서 실제 로그와 함께 풀어놓겠다. 미리 말하자면 코드 문제가 아니라, 시크릿과 환경을 안 맞춘 게 원인이었다. (이 글은 4월에 처음 썼고, 실제로 자동 배포를 접고 수동으로 돌아온 7월 초에 그 11번 실패 로그를 더해 내용을 보강했다.)
이 글은 그 삽질 과정에서 정리한 GitHub Actions 블로그 배포 파이프라인 구축 가이드다. 예시는 가장 흔한 정적 사이트 생성기인 Hugo를 기준으로 들지만, Astro·Jekyll·Next.js 어느 쪽이든 빌드 명령어만 바꾸면 그대로 적용된다. 개념부터 전체 YAML, 시크릿 관리, 그리고 내가 실제로 밟은 지뢰까지 순서대로 다룬다.
왜 블로그에 CI/CD가 필요한가
블로그 배포에 CI/CD가 과하다고 생각할 수 있다. “고작 정적 사이트 하나 올리는 건데 파이프라인까지 필요한가?” 솔직히 나도 처음엔 그렇게 생각했다. 그런데 수동 배포를 3개월쯤 하다 보면, 아래 상황이 반드시 발생한다.
- 빌드 환경 불일치: 로컬 Hugo 버전이 0.139.0인데, 어느 날 업데이트하고 나서 테마가 깨진다. 빌드 환경이 고정되어 있지 않으면 “내 컴퓨터에선 되는데?”가 반복된다.
- 배포 실수: draft 글 배포, 빌드 플래그 누락, 잘못된 브랜치에서 배포 등. 사람이 하면 실수가 섞인다.
- 롤백 불가: 수동 배포는 이전 버전으로 되돌리기 어렵다. GitHub Actions를 쓰면 이전 커밋으로 revert하고 push만 하면 자동으로 롤백된다.
- 멀티 디바이스 문제: 데스크톱에서만 배포할 수 있다면, iPad에서 글을 수정한 뒤 배포가 안 된다. CI/CD가 있으면 어디서든 push만 하면 된다.
결정적으로, 한 번 세팅해두면 유지보수가 거의 필요 없다. 워크플로우 파일 하나 작성하면 끝이다.
GitHub Actions 기본 개념 — workflow, trigger, job, step
GitHub Actions의 구조를 간단히 정리하면 이렇다. 이미 알고 있다면 다음 섹션으로 넘어가도 된다.
핵심 용어 4가지
| 용어 | 설명 | 비유 |
|---|---|---|
| Workflow | .github/workflows/ 아래의 YAML 파일 하나 |
레시피 전체 |
| Trigger | 워크플로우를 실행하는 이벤트 (push, schedule 등) | 조리 시작 신호 |
| Job | 독립적으로 실행되는 작업 단위. 각 Job은 별도 VM에서 돈다. | 조리 단계 (손질, 볶기, 플레이팅) |
| Step | Job 안의 개별 명령어. 쉘 커맨드 또는 Action을 실행한다. | 각 단계의 세부 동작 |
워크플로우 파일 위치와 기본 구조
워크플로우 파일은 반드시 리포지토리 루트의 .github/workflows/ 디렉토리에 위치해야 한다. 파일명은 자유지만, 목적이 드러나는 이름이 좋다.
my-blog/
├── .github/
│ └── workflows/
│ ├── deploy.yml # 배포 워크플로우
│ └── link-checker.yml # 링크 검증 (선택)
├── content/
│ └── posts/
├── themes/
├── config.toml
└── hugo.toml
가장 간단한 워크플로우의 뼈대는 이렇다.
# .github/workflows/deploy.yml
name: Deploy Blog
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: true # Hugo 테마가 submodule인 경우
fetch-depth: 0 # lastmod 등 git 정보 사용 시 필요
- name: Build
run: echo "여기에 빌드 명령어"
- name: Deploy
run: echo "여기에 배포 명령어"
on.push.branches에 main을 지정했으므로, main 브랜치에 push할 때만 실행된다. runs-on은 GitHub에서 제공하는 가상 머신 이미지인데, 2026년 기준 ubuntu-24.04가 안정적이다. fetch-depth: 0은 전체 커밋 히스토리를 가져오는 옵션으로, Hugo의 .GitInfo.AuthorDate 같은 기능을 쓸 때 필요하다.
Hugo 블로그 배포 워크플로우 전체 YAML
아래는 Hugo 블로그를 Cloudflare Pages에 배포하는 범용 워크플로우 예시다. 주석으로 각 라인의 역할을 달아뒀으니, 복사해서 자기 환경에 맞게 수정하면 된다. (내 블로그는 Astro라 build 스텝의 명령어만 다르고, 나머지 구조는 이것과 똑같이 붙였다 — 그리고 뒤에서 보겠지만 그 상태로 11번 깨졌다.)
# .github/workflows/deploy.yml
name: Build and Deploy Hugo Blog
on:
push:
branches:
- main
paths:
- 'content/**'
- 'layouts/**'
- 'static/**'
- 'config.toml'
- 'hugo.toml'
- 'themes/**'
workflow_dispatch: # GitHub UI에서 수동 실행 가능
# 동시 배포 방지: 같은 워크플로우가 중복 실행되면 이전 것을 취소
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
# GITHUB_TOKEN 권한을 최소한으로 제한
permissions:
contents: read
env:
HUGO_VERSION: '0.142.0'
HUGO_ENV: production
TZ: Asia/Seoul
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
# 1. 소스 코드 체크아웃
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive # 테마가 git submodule인 경우
fetch-depth: 0
# 2. Hugo 설치 — extended 버전 (SCSS 지원)
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
# 3. Hugo 모듈 캐시 (go module 기반 테마 사용 시)
- name: Cache Hugo modules
uses: actions/cache@v4
with:
path: /tmp/hugo_cache
key: hugo-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
hugo-${{ runner.os }}-
# 4. 빌드
- name: Build site
run: |
hugo \
--gc \
--minify \
--baseURL "${{ vars.SITE_URL }}" \
--cacheDir /tmp/hugo_cache
echo "✅ Build complete: $(find public -name '*.html' | wc -l) HTML files"
# 5. 빌드 결과물을 artifact로 업로드 (deploy job에서 사용)
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: hugo-site
path: public/
retention-days: 3
deploy:
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 5
environment:
name: production
url: ${{ vars.SITE_URL }}
steps:
# 1. 빌드 결과물 다운로드
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: hugo-site
path: public/
# 2. Cloudflare Pages에 배포
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public/ --project-name=${{ vars.CF_PROJECT_NAME }}
# 3. 배포 결과 출력
- name: Print deploy URL
run: |
echo "🚀 Deployed to: ${{ vars.SITE_URL }}"
echo "📅 Deploy time: $(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S KST')"
몇 가지 포인트를 짚어보자.
paths 필터: 모든 push에서 빌드가 도는 건 낭비다. README.md만 수정했는데 빌드가 돌 이유는 없다. paths에 실제 사이트에 영향을 주는 파일만 지정하면, 불필요한 빌드를 방지할 수 있다.
concurrency: 커밋을 빠르게 연속으로 push하면 워크플로우가 중복 실행된다. cancel-in-progress: true를 걸면, 앞선 실행을 자동 취소하고 최신 커밋으로만 배포한다. 이거 안 걸면 동시 배포로 꼬이는 경우가 생긴다.
build/deploy Job 분리: 하나의 Job으로 합칠 수도 있지만, 분리하면 build는 성공했는데 deploy만 실패한 경우 deploy만 재실행할 수 있다. GitHub Actions UI에서 개별 Job 재실행이 가능하기 때문이다.
Secrets 관리 — DEPLOY_KEY, API 토큰 설정
워크플로우에서 ${{ secrets.CLOUDFLARE_API_TOKEN }}처럼 참조하는 값은 GitHub 리포지토리 Settings에서 등록해야 한다. 이 값들은 로그에 마스킹되어 노출되지 않는다.
시크릿 등록 방법
리포지토리 → Settings → Secrets and variables → Actions → New repository secret 순서로 들어간다.
| Secret 이름 | 용도 | 발급 위치 |
|---|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare Pages 배포 인증 | Cloudflare Dashboard → API Tokens |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare 계정 식별 | Cloudflare Dashboard → 우측 사이드바 |
TELEGRAM_BOT_TOKEN |
배포 알림 전송 | Telegram BotFather |
TELEGRAM_CHAT_ID |
알림 받을 채팅방 ID | getUpdates API 호출 |
Secrets vs Variables 차이: Secrets는 로그에 ***로 마스킹되고 한 번 저장하면 다시 볼 수 없다. Variables(${{ vars.SITE_URL }})는 마스킹 없이 노출되므로, URL 같은 비밀이 아닌 설정값에 사용한다.
Cloudflare API 토큰 발급 시, 권한은 Cloudflare Pages: Edit만 있으면 된다. 과도한 권한을 주는 건 보안상 좋지 않다. 나는 처음에 전체 관리자 토큰을 넣었다가, 나중에 “이 토큰 유출되면 DNS까지 털리겠구나” 싶어서 최소 권한으로 다시 발급했다.
# Cloudflare API 토큰 테스트 — 로컬에서 먼저 확인
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json"
# 정상 응답 예시
# {"result":{"id":"abc123","status":"active"},"success":true}
# GitHub CLI로 Secret 등록 (웹 UI 대신)
gh secret set CLOUDFLARE_API_TOKEN --body "your-token-here"
gh secret set CLOUDFLARE_ACCOUNT_ID --body "your-account-id"
gh secret set TELEGRAM_BOT_TOKEN --body "7123456789:AAH_xxxxx"
gh secret set TELEGRAM_CHAT_ID --body "123456789"
# Variables는 별도 명령어
gh variable set SITE_URL --body "https://yourblog.pages.dev"
gh variable set CF_PROJECT_NAME --body "my-blog"
고급 설정 — 캐싱, 조건부 배포, 멀티 플랫폼
의존성 캐싱으로 빌드 시간 단축
Hugo 자체는 빠르지만, npm 기반 테마를 쓰거나 PostCSS를 돌리면 npm install이 병목이 된다. actions/cache로 node_modules를 캐싱하면 빌드 시간을 절반 이하로 줄일 수 있다.
# npm 의존성 캐싱 (PostCSS, Tailwind 등 사용 시)
- name: Cache node_modules
uses: actions/cache@v4
id: npm-cache
with:
path: node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
# 캐시 적중 시 npm ci를 건너뛰므로, 30초 이상 절약된다.
hashFiles('package-lock.json')이 핵심이다. package-lock.json이 바뀌지 않았으면 캐시를 그대로 사용하고, 바뀌었으면 npm ci를 새로 실행한다. npm install이 아니라 npm ci를 쓰는 이유는, ci가 lock 파일 기준으로 정확한 버전을 설치하기 때문이다. CI 환경에서는 항상 npm ci를 쓰자.
조건부 배포 — 특정 조건에서만 배포
PR 머지가 아니라 단순 push일 때는 빌드만 하고 배포는 건너뛰고 싶을 수 있다. if 조건으로 제어한다.
deploy:
needs: build
# main 브랜치 push일 때만 배포, PR 이벤트는 제외
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
# ... 배포 steps
# preview 배포: PR에서는 프리뷰 URL로 배포
deploy-preview:
needs: build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: hugo-site
path: public/
- name: Deploy preview
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public/ --project-name=${{ vars.CF_PROJECT_NAME }} --branch=${{ github.head_ref }}
Vercel / Netlify 배포 대안
Cloudflare Pages가 아닌 다른 플랫폼을 쓴다면, deploy step만 바꾸면 된다.
# --- Vercel 배포 ---
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
working-directory: public/
# --- Netlify 배포 ---
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v3
with:
publish-dir: public/
production-deploy: true
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
# --- GitHub Pages 배포 (가장 단순) ---
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
cname: yourblog.com # 커스텀 도메인 사용 시
GitHub Pages가 가장 설정이 간단하다. GITHUB_TOKEN은 GitHub이 자동으로 제공하므로 별도 시크릿 등록이 필요 없다. 다만 빌드 시간이 10분을 넘기면 GitHub Pages 자체 빌드 제한에 걸릴 수 있으니, Hugo 빌드는 Actions에서 하고 결과물만 Pages에 올리는 방식을 추천한다.
배포 알림 — Telegram과 Slack으로 결과 받기
배포가 끝나면 결과를 알림으로 받는 게 좋다. 특히 실패했을 때 빠르게 알 수 있어야 한다. 나는 Python으로 만들어둔 Telegram 봇을 그대로 쓰는데, Slack을 쓰는 팀이라면 Slack Webhook으로 대체하면 된다.
notify:
needs: [build, deploy]
runs-on: ubuntu-24.04
if: always() # 성공이든 실패든 항상 실행
steps:
- name: Set deploy status
id: status
run: |
if [ "${{ needs.deploy.result }}" == "success" ]; then
echo "emoji=✅" >> $GITHUB_OUTPUT
echo "text=배포 성공" >> $GITHUB_OUTPUT
elif [ "${{ needs.deploy.result }}" == "failure" ]; then
echo "emoji=❌" >> $GITHUB_OUTPUT
echo "text=배포 실패" >> $GITHUB_OUTPUT
else
echo "emoji=⚠️" >> $GITHUB_OUTPUT
echo "text=배포 스킵/취소" >> $GITHUB_OUTPUT
fi
# Telegram 알림
- name: Send Telegram notification
if: env.TELEGRAM_CONFIGURED == 'true'
env:
TELEGRAM_CONFIGURED: ${{ secrets.TELEGRAM_BOT_TOKEN != '' }}
run: |
COMMIT_MSG=$(echo "${{ github.event.head_commit.message }}" | head -1)
COMMIT_SHA="${{ github.sha }}"
SHORT_SHA="${COMMIT_SHA:0:7}"
REPO="${{ github.repository }}"
RUN_URL="https://github.com/${REPO}/actions/runs/${{ github.run_id }}"
MESSAGE="${{ steps.status.outputs.emoji }} *${{ steps.status.outputs.text }}*
📝 \`${SHORT_SHA}\` ${COMMIT_MSG}
🔗 [Actions 로그](${RUN_URL})
📅 $(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M KST')"
curl -s -X POST \
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d parse_mode="Markdown" \
-d text="${MESSAGE}" \
-d disable_web_page_preview=true
# Slack 알림 (Incoming Webhook 방식)
- name: Send Slack notification
if: env.SLACK_CONFIGURED == 'true'
env:
SLACK_CONFIGURED: ${{ secrets.SLACK_WEBHOOK_URL != '' }}
run: |
COMMIT_MSG=$(echo "${{ github.event.head_commit.message }}" | head -1)
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:7}"
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d "{
\"text\": \"${{ steps.status.outputs.emoji }} ${{ steps.status.outputs.text }}\",
\"blocks\": [
{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"${{ steps.status.outputs.emoji }} *${{ steps.status.outputs.text }}*\n\`${SHORT_SHA}\` ${COMMIT_MSG}\"
}
}
]
}"
if: always()가 중요하다. 이걸 안 쓰면 deploy Job이 실패했을 때 notify Job이 아예 실행되지 않는다. 실패했을 때야말로 알림이 필요한데, 기본 동작은 이전 Job이 성공해야 다음 Job이 실행되는 구조이기 때문이다. 이걸 놓치면 정작 “배포 실패했는데 왜 알림이 안 오지?” 하는, 알림이 가장 필요한 순간에 조용한 상황이 벌어진다.
트러블슈팅 — 자주 만나는 에러와 해결법
GitHub Actions를 처음 세팅하면 높은 확률로 만나는 에러들을 모았다. 아래 네 가지는 세팅 과정에서 흔히 밟는 지뢰들이고, 그 뒤에 이 블로그가 실제로 11번 실패한 사례를 로그와 함께 붙여뒀다.
1. YAML 문법 에러
YAML은 들여쓰기에 극도로 민감하다. 탭을 쓰면 안 되고, 스페이스 2칸이 표준이다.
# ❌ 이렇게 하면 에러
steps:
- name: Build
run: hugo --minify
env: # 들여쓰기가 한 칸 더 들어가서 파싱 에러
HUGO_ENV: production
# ✅ 올바른 들여쓰기
steps:
- name: Build
run: hugo --minify
env:
HUGO_ENV: production
# ❌ 콜론 뒤에 값이 바로 오면 에러 (특수문자 포함 시)
run: echo "deploy: started" # 이건 OK
run: echo deploy: started # ⚠️ 따옴표 없으면 파싱이 애매해짐
# ✅ 여러 줄 명령어는 | (literal block) 사용
run: |
echo "Step 1: Building"
hugo --minify
echo "Step 2: Done"
YAML 문법 검증은 로컬에서 먼저 하자. actionlint라는 도구가 GitHub Actions 전용 린터다.
# actionlint 설치 (macOS)
brew install actionlint
# 워크플로우 파일 검증
actionlint .github/workflows/deploy.yml
# 전체 워크플로우 한 번에 검증
actionlint
# VS Code를 쓴다면, YAML 확장 + actionlint 확장 설치 추천
# 저장할 때마다 실시간으로 오류를 잡아준다
2. Permission denied 에러
GITHUB_TOKEN의 기본 권한이 2023년 이후 read-only로 변경되었다. GitHub Pages에 배포하려면 write 권한이 필요하다.
# 워크플로우 레벨에서 권한 지정
permissions:
contents: read
pages: write # GitHub Pages 배포 시
id-token: write # OIDC 인증 사용 시
# 또는 리포지토리 Settings에서 변경:
# Settings → Actions → General → Workflow permissions
# "Read and write permissions" 선택
3. Action 버전 미고정
Action을 @v4처럼 메이저 버전 태그로 참조하는 것도 괜찮지만, 보안에 민감하다면 커밋 SHA를 고정하는 게 낫다. 태그는 덮어쓸 수 있기 때문이다.
# 일반적인 방식: 메이저 버전 태그
- uses: actions/checkout@v4 # v4의 최신 패치를 자동으로 사용
# 보안 강화: 커밋 SHA 고정 (supply chain attack 방지)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Dependabot으로 Action 버전 자동 업데이트
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "your-username"
4. 로컬과 CI 빌드 결과가 다를 때
Hugo 버전을 명시적으로 고정하지 않으면 이 문제가 반복된다. HUGO_VERSION을 환경변수로 관리하고, 로컬 Hugo 버전도 맞추자.
# 현재 로컬 Hugo 버전 확인
hugo version
# hugo v0.142.0-... extended
# CI에서 사용하는 버전과 로컬 버전이 다르면
# brew로 특정 버전 설치
brew install [email protected]
# 또는 .hugo-version 파일로 관리 (일부 호스팅에서 지원)
echo "0.142.0" > .hugo-version
Astro나 Next.js처럼 Node 기반 생성기라면 Node 버전부터 고정해야 한다. 로컬은 nvm의 .nvmrc로, CI는 actions/setup-node의 node-version-file 옵션으로 같은 파일을 바라보게 하면 두 환경이 어긋날 일이 없다.
5. 실전 사례 — 이 블로그 자동 배포가 11번 연속 실패한 이야기
앞의 네 에러가 교과서라면, 이건 내 실제 시험 성적표다. 이 블로그(Astro + Cloudflare Pages)에 위와 같은 GitHub Actions 자동 배포를 붙였는데, push할 때마다 실패해서 결국 11번 연속으로 빨간 X를 봤다. 커밋할 때마다 “빌드 실패” 메일이 날아오는 게 스트레스라, 작정하고 원인을 파고들었다.
$ gh run list --workflow="Deploy to Cloudflare Pages" --limit 11
completed failure 고아 이미지 39개 삭제 ... Deploy to Cloudflare Pages main push 43s
completed failure 사이트 title 키워드화 + FAQ ... Deploy to Cloudflare Pages main push 1m3s
completed failure 양산 콘텐츠 21글 삭제 ... Deploy to Cloudflare Pages main push 1m13s
...
# 11개 전부 failure. 성공 기록이 단 하나도 없었다.
로그를 스텝별로 뜯어보니, 실패 지점이 시기별로 달랐다.
- 초반엔 빌드는 통과, 배포 스텝에서 죽었다.
npm run build까지 초록불인데Publish to Cloudflare Pages(cloudflare/wrangler-action) 스텝에서exit code 1. 원인은 둘이었다. 하나는 CF 시크릿 미등록 —CLOUDFLARE_API_TOKEN과CLOUDFLARE_ACCOUNT_ID를 리포지토리 Secrets에 넣은 적이 없어서 wrangler-action이 인증을 못 했다. 다른 하나는--project-name불일치 — 워크플로우엔--project-name=khiopost-finance라고 적혀 있는데 실제 Cloudflare Pages 프로젝트명은khiopost였다. 존재하지 않는 프로젝트에 배포하려 했으니 될 리가 없었다. - 나중엔 빌드 스텝 자체가 죽었다. 빌드 스크립트에 넣은
find dist -name '*.html' -exec perl ... {} +명령이 문제였다. 내 맥(BSD find)에선 멀쩡히 돌던 게 CI 러너(Ubuntu, GNU find)에선{}처리 방식이 달라Only one instance of {} is supported로 깨졌다. “로컬에선 되는데 CI에선 안 되는” 전형적인 케이스다.
결국 나는 push 트리거를 아예 꺼버렸다. on:에서 push를 빼고 workflow_dispatch(수동 실행)만 남긴 뒤, 배포는 로컬에서 wrangler pages deploy dist --project-name=khiopost로 직접 하는 방식으로 돌아왔다. 글 몇 개짜리 블로그에선 자동화의 편의보다 “실패 메일 안 오는 평온함”이 더 컸다.
이 삽질에서 건진 교훈 세 가지.
- 자동배포를 켜기 전에 시크릿부터 등록하고 검증하라. 코드가 아무리 맞아도 시크릿이 비어 있으면 배포 스텝은 무조건 죽는다.
gh secret list로 먼저 확인하는 습관을 들이자. --project-name은 실제 프로젝트명과 글자 하나까지 맞춰라. 리브랜딩하면서 이름을 바꿨다면 워크플로우도 같이 고쳐야 한다. 나는 이 한 줄 때문에 며칠을 날렸다.- 빌드 스크립트의 셸 명령은 로컬과 CI의 환경 차이를 의심하라. 특히
find,sed,xargs는 BSD(macOS)와 GNU(Linux)에서 동작이 미묘하게 다르다. 로컬에서 됐다고 CI에서 된다는 보장이 없다.
에러 대응 빠른 참조표
| 증상 | 원인 | 해결 |
|---|---|---|
| 워크플로우가 아예 안 뜸 | 파일 위치가 .github/workflows/가 아님 |
디렉토리 경로 확인, 오타 체크 |
| push해도 트리거 안 됨 | branches 필터 불일치 또는 paths 필터에 안 걸림 |
브랜치명, 변경 파일 경로 확인 |
Error: Process completed with exit code 128 |
Git 인증 실패 (submodule, private repo) | token 옵션으로 PAT 전달 |
Error: Input required and not supplied: token |
Secret 이름 오타 또는 미등록 | Settings → Secrets에서 이름 정확히 확인 |
| 캐시가 계속 miss | key에 사용한 hashFiles 경로 오류 |
파일 존재 여부, 경로 패턴 확인 |
트러블슈팅에서 가장 중요한 건 Actions 탭의 로그를 꼼꼼히 읽는 것이다. 에러 메시지를 그대로 복사해서 검색하면 대부분 해결책이 나온다. 그리고 workflow_dispatch 트리거를 추가해두면, 코드 변경 없이 GitHub UI에서 수동으로 워크플로우를 실행해볼 수 있어서 디버깅이 훨씬 편하다.
여기까지가 GitHub Actions로 블로그 배포를 자동화하는 전체 그림이다. 다만 내 11번의 실패가 말해주듯, 자동화의 8할은 “YAML을 붙이는 것”이 아니라 “시크릿과 환경을 정확히 맞추는 것”에 달려 있다. 나처럼 헛발질하기 싫다면, 자동배포를 켜기 전에 시크릿부터 등록하고 gh secret list로 확인한 뒤, --project-name이 실제 프로젝트명과 일치하는지부터 챙기자. 그게 번거롭다면, 나처럼 당분간 수동 wrangler 배포로 두는 것도 전혀 나쁘지 않다 — 글 몇 개짜리 블로그라면 오히려 그게 속 편하다. 더 깊은 설정은 GitHub Actions 공식 문서를 참고하자.