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해도, 날짜가 다르면 사흘에 걸쳐 하나씩 열립니다. 그날의 재빌드가 실제로 떴다는 전제에서입니다 — 위 정정에 적은 대로 그게 보장되지는 않습니다.
정리

- “설정에 적어뒀으니 막힐 것"은 확인 전까지는 짐작입니다. 경고만 내고 통과하는 설정이 있습니다
- 같은 값을 두 곳에 적게 되면 한쪽이 다른 쪽을 읽어가게 만드는 편이 낫습니다
- 배포가 성공했다는 것과 사이트가 열린다는 것은 다른 사실이라 따로 확인합니다
- 자동화 토큰에 IP 제한은 안 됩니다. 대신 권한 범위를 좁힙니다
- 정적 사이트의 예약 발행은 날짜가 됐다고 저절로 되지 않습니다. 빌드가 트리거입니다