Skip to content
khiopost — 개발·AI 트러블슈팅
Go back

GitHub Actions 블로그 배포 자동화 — 11번 실패하고 정리한 CI/CD 가이드

Updated:

블로그에 글을 올릴 때마다 수동으로 빌드하고 배포하는 건, 한두 번은 괜찮지만 반복되면 상당한 시간 낭비다. 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개월쯤 하다 보면, 아래 상황이 반드시 발생한다.

결정적으로, 한 번 세팅해두면 유지보수가 거의 필요 없다. 워크플로우 파일 하나 작성하면 끝이다.

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.branchesmain을 지정했으므로, 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/cachenode_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-nodenode-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. 성공 기록이 단 하나도 없었다.

로그를 스텝별로 뜯어보니, 실패 지점이 시기별로 달랐다.

결국 나는 push 트리거를 아예 꺼버렸다. on:에서 push를 빼고 workflow_dispatch(수동 실행)만 남긴 뒤, 배포는 로컬에서 wrangler pages deploy dist --project-name=khiopost로 직접 하는 방식으로 돌아왔다. 글 몇 개짜리 블로그에선 자동화의 편의보다 “실패 메일 안 오는 평온함”이 더 컸다.

이 삽질에서 건진 교훈 세 가지.

  1. 자동배포를 켜기 전에 시크릿부터 등록하고 검증하라. 코드가 아무리 맞아도 시크릿이 비어 있으면 배포 스텝은 무조건 죽는다. gh secret list로 먼저 확인하는 습관을 들이자.
  2. --project-name은 실제 프로젝트명과 글자 하나까지 맞춰라. 리브랜딩하면서 이름을 바꿨다면 워크플로우도 같이 고쳐야 한다. 나는 이 한 줄 때문에 며칠을 날렸다.
  3. 빌드 스크립트의 셸 명령은 로컬과 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 공식 문서를 참고하자.


글쓴이 — khio · 10년차 백엔드 개발자. 직접 겪은 문제와 해결 과정을 기록한다.
Share this post on:

Previous Post
Jump Desktop으로 iPad에서 Mac 원격 접속 3가지 방법 비교
Next Post
GitHub Codespaces를 iPad로 6개월 굴려봤다 — 되는 일과 여전히 걸리는 것