birchholt 직접 해보고 남기는 기록

push 한 번으로 배포되게 만들면서 걸린 것들

이 사이트는 main에 push하면, 그러니까 고친 내용을 저장소로 올리면 알아서 배포됩니다. 글 파일을 웹페이지로 만드는 빌드를 거쳐 Cloudflare Pages에 올리고 올라간 게 실제로 열리는지까지 확인합니다. 글을 웹페이지로 만드는 건 Hugo, 그 과정을 대신 돌려주는 건 GitHub Actions입니다.

구슬 하나가 굴러 오두막 문까지 가는 긴 나무 레일과 난간이 빠진 한 구간

만드는 데 오래 걸리진 않았는데, 당연히 될 거라고 믿었던 것 하나가 안 되고 있었습니다. 그것부터 적습니다.

1. 설정 파일이 막아줄 거라 믿었는데, 경고만 냈습니다

경고 종만 울리는 차단기를 그대로 지나는 수레와 단단한 빗장을 거는 사람

Hugo에는 최소 버전을 적어두는 자리가 있습니다. 저는 이렇게 적어뒀습니다.

[module]
  [module.hugoVersion]
    min = "0.165.0"
    extended = true

이러면 낮은 버전에서는 빌드가 막힐 거라고 생각했습니다. 아니었습니다. 확인해보니 경고(WARN)만 찍고 빌드는 그대로 통과합니다.

2026-09-22에 다시 재현했습니다. 저장소의 hugo.toml 은 건드리지 않고, min 만 설치본보다 높게(99.0.0) 적은 임시 설정을 덧씌워 빌드했습니다 — hugo --config hugo.toml,/tmp/minbump.toml --renderToMemory. WARN Module "project" is not compatible with this Hugo version 한 줄이 찍힌 뒤 25쪽을 다 만들고 종료 코드 0으로 끝났습니다. Hugo v0.165.0+extended 기준입니다.

문제는 이게 조용하다는 겁니다. 제 컴퓨터(로컬)에는 최신 Hugo가 깔려 있으니 아무 일도 없고 빌드 로그에 경고 한 줄이 지나가도 볼 일이 없습니다. 드러난다면 한참 뒤, 다른 환경에서 빌드할 때입니다.

실제로 막는 건 빌드를 감싸는 스크립트(build.sh)에 따로 만들었습니다.

MIN="0.165.0"

RAW="$(hugo version)"
VER="$(printf '%s' "$RAW" | sed -E 's/^hugo v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')"

# extended 빌드인지 (이 사이트엔 없어도 되는 검사입니다 — 아래 정정 참고)
if ! printf '%s' "$RAW" | grep -q 'extended'; then
  echo "🔴 extended 빌드가 아니다: $RAW" >&2
  exit 1
fi

# 버전 비교는 sort -V 로
LOWEST="$(printf '%s\n%s\n' "$MIN" "$VER" | sort -V | head -1)"
if [ "$LOWEST" != "$MIN" ]; then
  echo "🔴 hugo $VER 은 최소 요구 버전 $MIN 보다 낮다." >&2
  exit 1
fi

정정 (2026-09-20) — 여기에 “extended 빌드가 아니면 CSS 처리(fingerprint 등)이 동작하지 않는다"고 적었는데 틀렸습니다. extended 에디션이 더 갖는 것은 Sass 변환과 WebP 인코딩입니다. Hugo 문서는 css.Sass에 extended가 필요하다고 명시하지만 resources.Fingerprint에는 그런 표시가 없습니다. 이 사이트의 CSS는 resources.Get "css/main.css" | fingerprint 한 줄이라 표준 에디션에서도 빌드됩니다. 지금 이 사이트에는 없어도 되는 검사입니다. 버전 숫자만으로는 에디션을 알 수 없다는 것만 그대로 맞습니다.

2. 같은 버전을 두 군데 적지 않기

큰 괘종시계 하나에 줄로 이어져 같은 방향을 가리키는 작은 시계 두 개

여기서 새 문제가 생겼습니다. 이제 Hugo 버전이 두 곳에 있습니다 — hugo.toml과 빌드 스크립트. 그리고 배포 워크플로(GitHub Actions가 대신 돌려주는 배포 절차)도 설치할 버전을 알아야 하니 세 곳입니다.

세 곳에 적어두면 언젠가 어긋납니다. 어긋나도 한동안 모릅니다.

그래서 워크플로가 스크립트에서 직접 읽어가게 했습니다.

- name: build.sh 에서 Hugo 버전 읽기
  run: |
    VER="$(grep -E '^MIN=' build.sh | cut -d'"' -f2)"
    if [ -z "$VER" ]; then
      echo "🔴 build.sh 에서 MIN 을 못 읽었다" >&2
      exit 1
    fi
    echo "HUGO_VERSION=$VER" >> "$GITHUB_ENV"

못 읽으면 거기서 멈추게 한 것이 중요합니다. 빈 값으로 넘어가면 이상한 버전을 설치하고 나서 엉뚱한 데서 실패합니다.

버전을 올릴 때는 build.sh 한 줄만 고치면 됩니다.

3. 연속으로 push하면 배포 순서가 뒤집힙니다

운하에서 먼저 띄운 배를 그물로 건져내고 나중 배만 부두로 보내는 사람

오타를 고치고 바로 또 push하는 일이 잦습니다. 그러면 워크플로 두 개가 동시에 돕니다. 먼저 시작한 게 나중에 끝나면 옛날 내용이 실제 사이트에 남습니다.

이 사이트는 빌드한 결과물 폴더를 통째로 올리는 방식이라, 늦게 도착한 쪽이 그대로 덮어씁니다.

concurrency:
  group: deploy-pages
  cancel-in-progress: true

앞선 실행을 취소하고 마지막 것만 배포합니다.

이 설정은 파이프라인을 처음 만든 커밋(2026-08-26)에 같이 들어갔습니다. 순서가 뒤집히는 걸 이 사이트에서 보고 나서 넣은 게 아니라, 막아두고 시작한 쪽입니다.

4. 배포가 끝났다고 열리는 건 아닙니다

보내기 전 상자 속 꾸러미를 세는 사람과 도착한 가게 문을 직접 열어보는 손

배포 단계가 초록불이어도 사이트가 깨져 있을 수 있습니다. 빌드가 성공해도 나와야 할 파일이 빠져 있을 수 있기 때문입니다. 결과물이 통째로 비는 걸 겪어서 넣은 검사는 아닙니다. 실제로 이 사이트에서 걸린 건 파일 하나가 빠진 쪽이었고, 그건 따로 적었습니다.

두 단계를 붙였습니다. 올리기 전에 파일이 있는지,

- name: 산출물 확인
  run: |
    test -f public/index.html      || { echo "🔴 index.html 이 없다" >&2; exit 1; }
    test -f public/ads.txt         || { echo "🔴 ads.txt 가 없다" >&2; exit 1; }
    test -f public/privacy/index.html || { echo "🔴 privacy 페이지가 없다" >&2; exit 1; }

올린 다음에 진짜 열리는지를 봅니다.

- name: 라이브 검증
  run: |
    sleep 10
    for p in / /privacy/ /ads.txt; do
      code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://birchholt.com$p")"
      echo "$p → $code"
      [ "$code" = "200" ] || { echo "🔴 $p 가 200 이 아니다" >&2; exit 1; }
    done

sleep 10은 올린 직후 바로 찍으면 아직 옛 내용이 나올까 봐 넣었습니다. 재보고 정한 값은 아니고 넉넉하게 잡았습니다.

위 두 조각은 이 파이프라인을 처음 만들었을 때(2026-08-26)의 검사입니다. 지금 돌고 있는 워크플로에는 산출물 쪽에 test -f public/404.html 한 줄이, 라이브 쪽에 없는 주소가 정말 404를 돌려주는지 찍어보는 검사가 더 붙어 있습니다. 2026-08-31에 추가했고, 왜 필요했는지는 없는 주소가 200을 돌려주고 있었습니다에 적었습니다.

5. 토큰에 IP 제한을 걸지 마세요

여러 갈래 길이 모이는 작업대에서 헛간 하나만 여는 작은 열쇠를 깎는 사람

이건 걸기 전에 알아본 것이라 겪지는 않았습니다.

Cloudflare API 토큰(프로그램이 사람 대신 로그인할 때 쓰는 긴 비밀번호)은 호출 IP를 제한할 수 있습니다. 보안 습관대로면 걸고 싶어집니다. 그런데 작업을 실제로 돌려주는 서버, 즉 GitHub Actions의 기본 러너에는 고정 IP가 없습니다. GitHub 문서는 러너가 쓰는 IP 목록이 주 단위로 갱신된다고 적고, 고정 IP가 필요하면 더 큰 러너(larger runner)나 직접 띄운 러너를 쓰라고 안내합니다. 기본 러너로 돌리면서 IP를 고정해두면 배포가 막히게 됩니다.

GitHub Actions · GitHub-hosted runners — 「The list of GitHub Actions IP addresses returned by the API is updated once a week」, 고정 IP가 필요한 경우에는 「we recommend you use larger runners with a static IP address range, or self-hosted runners」. 2026-09-22에 확인했습니다.

대신 권한 범위를 좁히는 쪽으로 갔습니다 — Cloudflare Pages 편집 권한 하나만 준 토큰입니다.

토큰에 만료일을 설정했다면 갱신 알림을 같이 걸어두세요. 만료되면 배포 단계에서 실패할 텐데, 실패 알림을 따로 보고 있지 않으면 올린 글이 안 올라가 있다는 걸 한참 뒤에 발견하게 됩니다. 겪어본 게 아니라 토큰을 만들면서 짚어둔 것입니다.

6. 예약 발행은 정적 사이트에서 그냥은 안 됩니다

새벽마다 도는 물뿌리개 아래 하루씩 차례로 싹이 트는 화분 세 개

글을 미리 써두고 날짜를 벌려 올리고 싶었습니다. Hugo에는 미래 날짜를 적어두면 되는 걸로 알고 있었는데, 반만 맞았습니다.

직접 확인해봤습니다. 미래 날짜 글을 하나 넣고 두 번 빌드했습니다.

$ hugo                 → posts/ 에 그 글 없음
$ hugo --buildFuture   → posts/ 에 그 글 있음

미래 날짜 글이 빌드에서 빠지는 건 맞습니다. 그 다음이 문제입니다.

정적 사이트는 빌드한 결과물이 그대로 올라가 있는 것이라, 날짜가 됐다고 저절로 나타나지 않습니다. 빌드가 한 번 더 돌아야 합니다. 그런데 워크플로를 시작시키는 조건, 즉 트리거가 push밖에 없으면 그걸 돌릴 사람이 없습니다.

트리거를 하나 더 붙여서 해결했습니다.

on:
  push:
    branches: [main]
  schedule:
    - cron: '23 1 * * *'   # 01:23 UTC = 10:23 KST
    - cron: '23 4 * * *'   # 04:23 UTC = 13:23 KST  (1차가 안 뜨면)
    - cron: '23 7 * * *'   # 07:23 UTC = 16:23 KST  (2차가 안 뜨면)
  workflow_dispatch:

정정 (2026-09-20) — 처음에는 0 1 * * * 한 줄로 적어뒀는데, 그 스케줄이 실제로 발화하지 않았습니다. 2026-09-01과 09-03에 예약 글이 제 날짜에 안 열렸습니다. GitHub 문서는 부하가 높은 시간대에 스케줄 이벤트가 지연될 수 있고 그 시간대에 매시 정각이 포함된다고 적습니다. 그래서 건너뛰어도 다음이 잡도록 정각을 피하고 하루 세 번으로 늘렸습니다. 급하면 workflow_dispatch로 직접 돌립니다.

이제 오늘 세 편을 다 써서 한 번에 push해도, 날짜가 다르면 사흘에 걸쳐 하나씩 열립니다. 그날의 재빌드가 실제로 떴다는 전제에서입니다 — 위 정정에 적은 대로 그게 보장되지는 않습니다.

정리

공구함 서랍 칸마다 모양이 다른 나무 조각 다섯 개를 놓는 두 손