From 88335b960010a90577d84cb98bd2d17f9eba2fc6 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 7 Oct 2026 22:47:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20CDN=20=ED=8C=8C=EC=9D=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20URL=20=EC=A0=84=ED=99=98=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.file.url-provider를 APP_FILE_URL_PROVIDER 환경변수로 노출 (기본 S3) - CDN 모드에서 CDN_BASE_URL이 비어 있으면 기동 실패하도록 변경 - base URL 공백/끝 슬래시 정규화 및 테스트 보강 - FileResponse.fileUrl Swagger 예시를 CDN URL로 변경 Co-Authored-By: Claude Opus 5.5 --- .../application/dto/response/FileResponse.kt | 2 +- .../infrastructure/CdnFileAccessUrlAdapter.kt | 17 ++++++++--------- src/main/resources/application.yml | 1 + .../infrastructure/FileAccessUrlAdapterTest.kt | 12 +++++++++--- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt index eecc14e3d..0ea220e39 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt @@ -10,7 +10,7 @@ data class FileResponse( val fileName: String, @field:Schema( description = "조회용 파일 URL", - example = "https://bucket.s3.ap-northeast-2.amazonaws.com/POST/2026-02/uuid_profile-image.png", + example = "https://cdn.weeth.kr/POST/2026-02/uuid_profile-image.png", ) val fileUrl: String, @field:Schema(description = "저장소 키", example = "POST/2026-02/uuid_profile-image.png") diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt index d9fcddb03..1b4c134d7 100644 --- a/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt @@ -12,15 +12,14 @@ import org.springframework.stereotype.Component havingValue = "CDN", // CDN으로 설정된 경우 이 어댑터를 사용합니다. ) class CdnFileAccessUrlAdapter( - @Value("\${app.file.cdn-base-url:}") private val cdnBaseUrl: String, + @Value("\${app.file.cdn-base-url:}") cdnBaseUrl: String, ) : FileAccessUrlPort { - /** storageKey를 CDN 조회 URL로 변환합니다. */ - override fun resolve(storageKey: String): String { - val normalizedBaseUrl = cdnBaseUrl.trimEnd('/') - return if (normalizedBaseUrl.isBlank()) { - storageKey - } else { - "$normalizedBaseUrl/$storageKey" + // base URL 누락 시 storageKey만 담긴 깨진 URL이 응답되므로 기동 시점에 실패시킨다 + private val baseUrl = + cdnBaseUrl.trim().trimEnd('/').also { + require(it.isNotBlank()) { "app.file.url-provider=CDN이면 app.file.cdn-base-url(CDN_BASE_URL)을 설정해야 합니다." } } - } + + /** storageKey를 CDN 조회 URL로 변환합니다. */ + override fun resolve(storageKey: String): String = "$baseUrl/$storageKey" } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 21821e3f7..d5b08769d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -73,6 +73,7 @@ management: app: file: + url-provider: ${APP_FILE_URL_PROVIDER:S3} # S3 | CDN cdn-base-url: ${CDN_BASE_URL:} presigned-url-expiration-minutes: 5 diff --git a/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt b/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt index 31e4520cf..e54cc8900 100644 --- a/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt @@ -1,6 +1,7 @@ package com.weeth.domain.file.infrastructure import com.weeth.global.config.properties.AwsS3Properties +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -31,12 +32,17 @@ class FileAccessUrlAdapterTest : result shouldBe "https://cdn.example.com/POST/2026-02/file.png" } - it("cdn base url이 없으면 storageKey를 그대로 반환한다") { - val adapter = CdnFileAccessUrlAdapter("") + it("cdn base url 끝의 슬래시를 정규화한다") { + val adapter = CdnFileAccessUrlAdapter("https://cdn.example.com/") val result = adapter.resolve("POST/2026-02/file.png") - result shouldBe "POST/2026-02/file.png" + result shouldBe "https://cdn.example.com/POST/2026-02/file.png" + } + + it("cdn base url이 비어 있으면 생성 시 예외가 발생한다") { + shouldThrow { CdnFileAccessUrlAdapter("") } + shouldThrow { CdnFileAccessUrlAdapter(" / ") } } } }) From ab1c66fbd2636d3337d668b086eef9460f6abcd5 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:23:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20(main=20=EB=A8=B8=EC=A7=80=20=EA=B8=B0=EC=A4=80=20d?= =?UTF-8?q?raft=20+=20=EB=B0=B1=EB=A8=B8=EC=A7=80=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=20+=20Slack=20=EC=95=8C=EB=A6=BC)=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: 릴리즈 드래프트를 main 머지 기준으로 변경 Co-Authored-By: Claude Opus 5.5 * ci: 릴리즈 draft 생성과 운영 배포 결과 Slack 알림 Co-Authored-By: Claude Opus 5.5 * ci: draft 알림 아이콘을 로켓으로 변경 Co-Authored-By: Claude Opus 5.5 * ci: draft 알림에서 bump·PR 정보 제거 Co-Authored-By: Claude Opus 5.5 * ci: Slack 알림에 저장소 이름 표시 Co-Authored-By: Claude Opus 5.5 * ci: 백머지 판정과 PR 라벨러 리뷰 반영 - 백머지 필요 여부를 merge-tree 결과로 판정해 머지 커밋의 충돌 해결분도 감지 - 기존 백머지 PR 조회 시 같은 저장소 PR만, 최대 100건 조회 - labeler v6 + pull_request_target으로 fork PR에도 라벨 부여 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit efaa9c8a862c5b784f1457e5b2704f0818a9170d) --- .github/labeler.yml | 27 +++++ .github/release-drafter.yml | 59 ----------- .github/release.yml | 34 ++++++ .github/scripts/release-draft.sh | 141 ++++++++++++++++++++++++ .github/scripts/release-draft.test.sh | 147 ++++++++++++++++++++++++++ .github/workflows/backmerge.yml | 79 ++++++++++++++ .github/workflows/deploy-prod.yml | 61 +++++++++++ .github/workflows/pr-labeler.yml | 20 ++++ .github/workflows/release-create.yml | 47 ++++++++ .github/workflows/release-drafter.yml | 20 ---- 10 files changed, 556 insertions(+), 79 deletions(-) create mode 100644 .github/labeler.yml delete mode 100644 .github/release-drafter.yml create mode 100644 .github/release.yml create mode 100644 .github/scripts/release-draft.sh create mode 100644 .github/scripts/release-draft.test.sh create mode 100644 .github/workflows/backmerge.yml create mode 100644 .github/workflows/pr-labeler.yml create mode 100644 .github/workflows/release-create.yml delete mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..c94f135b8 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,27 @@ +# PR head 브랜치 접두사 → 유형 라벨 (릴리즈 노트 분류용, 버전 판정과 무관) +# 브랜치 규칙: .claude/skills/git-prepare/SKILL.md + +"✨ Feature": + - head-branch: ["^feat/"] + +"🐞 BugFix": + - head-branch: ["^fix/"] + +"🔨 Refactor": + - head-branch: ["^refactor/"] + +"⚙ Setting": + - head-branch: ["^chore/"] + +"📃 Docs": + - head-branch: ["^docs/"] + +"✅ Test": + - head-branch: ["^test/"] + +"🚑 Hotfix": + - head-branch: ["^hotfix/"] + +# dev → main 릴리즈 PR (릴리즈 노트에서 제외) +"🚀 Release": + - head-branch: ["^dev$", "^release/"] diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 61e737f68..000000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,59 +0,0 @@ -name-template: "v$RESOLVED_VERSION" -tag-template: "v$RESOLVED_VERSION" -commitish: "dev" -filter-by-commitish: true -pull-request-limit: 20 -history-limit: 100 - -categories: - - title: "✨ Features" - labels: ["✨ Feature"] - - title: "🐞 Bug Fixes" - labels: ["🐞 BugFix"] - - title: "🔐 Security" - labels: ["🔐 Security"] - - title: "🔨 Refactors" - labels: ["🔨 Refactor"] - - title: "✅ Tests" - labels: ["✅ Test"] - - title: "📃 Docs" - labels: ["📃 Docs"] - - title: "🌏 Deploy" - labels: ["🌏 Deploy"] - - title: "⚙ Settings" - labels: ["⚙ Setting"] - - title: "💻 Cross Browsing" - labels: ["💻 CrossBrowsing"] - - title: "📬 API" - labels: ["📬 API"] - - title: "🤖 Agent" - labels: ["🤖 Agent"] - -change-template: "- $TITLE (#$NUMBER) @$AUTHOR" -change-title-escapes: "<*_&" - -version-resolver: - major: - labels: ["💥 Breaking"] - - minor: - labels: ["✨ Feature"] - - patch: - labels: - - "🐞 BugFix" - - "🔐 Security" - - "🔨 Refactor" - - "✅ Test" - - "📃 Docs" - - "🌏 Deploy" - - "⚙ Setting" - - "💻 CrossBrowsing" - - "📬 API" - - "🤖 Agent" - - default: patch - -template: | - ## Changes - $CHANGES diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..d223d6565 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,34 @@ +# generate-notes API 카테고리 설정 (release-create.yml 에서 사용) +changelog: + exclude: + labels: + - "🚀 Release" + categories: + - title: "💥 Breaking Changes" + labels: ["💥 Breaking"] + - title: "🚑 Hotfixes" + labels: ["🚑 Hotfix"] + - title: "✨ Features" + labels: ["✨ Feature"] + - title: "🐞 Bug Fixes" + labels: ["🐞 BugFix"] + - title: "🔐 Security" + labels: ["🔐 Security"] + - title: "🔨 Refactors" + labels: ["🔨 Refactor"] + - title: "✅ Tests" + labels: ["✅ Test"] + - title: "📃 Docs" + labels: ["📃 Docs"] + - title: "🌏 Deploy" + labels: ["🌏 Deploy"] + - title: "⚙ Settings" + labels: ["⚙ Setting"] + - title: "💻 Cross Browsing" + labels: ["💻 CrossBrowsing"] + - title: "📬 API" + labels: ["📬 API"] + - title: "🤖 Agent" + labels: ["🤖 Agent"] + - title: "🧩 Others" + labels: ["*"] diff --git a/.github/scripts/release-draft.sh b/.github/scripts/release-draft.sh new file mode 100644 index 000000000..e871d9a53 --- /dev/null +++ b/.github/scripts/release-draft.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# main 머지 커밋을 타깃으로 draft 릴리즈를 생성하거나, 이미 있는 draft를 갱신한다. +# +# 입력(env) +# TARGET_SHA (필수) draft가 가리킬 main 커밋 SHA +# GH_REPO (필수) owner/repo +# GH_TOKEN (필수) contents: write 권한 토큰 +# BUMP_INPUT (선택) major|minor|patch — 지정 시 브랜치/라벨 판정을 건너뛴다 (workflow_dispatch) +# HEAD_REF (선택) 머지된 PR의 head 브랜치 +# PR_LABELS (선택) 머지된 PR의 라벨 JSON 배열 +# SLACK_WEBHOOK_URL (선택) 설정 시 draft 생성/갱신을 Slack으로 알린다 +set -euo pipefail + +SEMVER_RE='^v?[0-9]+\.[0-9]+\.[0-9]+$' + +# hotfix는 patch, 릴리즈 PR은 라벨로 판정(기본 minor), 그 외 main 직행 PR은 patch +resolve_bump() { + local head_ref="$1" labels="$2" + case "$head_ref" in + hotfix/*) echo patch ;; + dev | release/*) + if grep -qxF '💥 Breaking' <<<"$labels"; then + echo major + elif grep -qxF '🩹 Patch' <<<"$labels"; then + echo patch + else + echo minor + fi + ;; + *) echo patch ;; + esac +} + +# next_version v1.2.3 minor → 1.3.0 +next_version() { + local major minor patch + IFS=. read -r major minor patch <<<"${1#v}" + case "$2" in + major) echo "$((major + 1)).0.0" ;; + minor) echo "$major.$((minor + 1)).0" ;; + patch) echo "$major.$minor.$((patch + 1))" ;; + *) + echo "unknown bump: $2" >&2 + return 1 + ;; + esac +} + +sort_versions() { sort -t. -k1,1n -k2,2n -k3,3n; } + +# max_version v1.3.0 1.2.1 → 1.3.0 +max_version() { printf '%s\n' "${1#v}" "${2#v}" | sort_versions | tail -1; } + +# stdin의 태그 목록 중 가장 높은 semver 태그 (없으면 빈 문자열) +highest_tag() { { grep -E "$SEMVER_RE" || true; } | sed 's/^v//' | sort_versions | tail -1 | sed '/./s/^/v/'; } + +latest_published_tag() { + gh release list --exclude-drafts --exclude-pre-releases --limit 100 \ + --json tagName -q '.[].tagName' | highest_tag +} + +existing_draft_tag() { + gh release list --limit 100 --json tagName,isDraft \ + -q '.[] | select(.isDraft) | .tagName' | highest_tag +} + +generate_notes() { + local tag="$1" prev="$2" + local args=(-f tag_name="$tag" -f target_commitish="$TARGET_SHA") + [[ -n "$prev" ]] && args+=(-f previous_tag_name="$prev") + gh api "repos/$GH_REPO/releases/generate-notes" "${args[@]}" -q .body +} + +# 알림 실패가 draft 생성을 실패로 만들지 않도록 경고만 남긴다 +notify_slack() { + [[ -z "${SLACK_WEBHOOK_URL:-}" ]] && return 0 + local payload + payload=$(jq -nc --arg text "$1" '{text: $text}') + if ! curl -fsS --max-time 10 -H 'Content-Type: application/json' -d "$payload" "$SLACK_WEBHOOK_URL" >/dev/null; then + echo "::warning::Slack 알림 전송 실패" + fi +} + +draft_message() { + local tag="$1" draft_tag="$2" url="$3" action + if [[ -z "$draft_tag" ]]; then + action="생성" + elif [[ "$draft_tag" == "$tag" ]]; then + action="갱신" + else + action="갱신 ($draft_tag → $tag)" + fi + # 한 채널에 여러 저장소 알림이 모이므로 저장소 이름을 앞에 붙인다 + printf '🚀 *[%s]* %s draft %s\n확인 후 Publish: <%s|%s draft 열기>' "${GH_REPO#*/}" "$tag" "$action" "$url" "$tag" +} + +main() { + : "${TARGET_SHA:?TARGET_SHA is required}" + : "${GH_REPO:?GH_REPO is required}" + + local bump + if [[ -n "${BUMP_INPUT:-}" ]]; then + bump="$BUMP_INPUT" + else + bump=$(resolve_bump "${HEAD_REF:-}" "$(jq -r '.[]?' <<<"${PR_LABELS:-[]}")") + fi + + local prev_tag draft_tag version tag notes out url + prev_tag=$(latest_published_tag) + version=$(next_version "${prev_tag:-v0.0.0}" "$bump") + + # publish 전에 main 머지가 여러 번 쌓이면 draft 하나에 모으고, 가장 큰 bump 버전을 유지한다 + draft_tag=$(existing_draft_tag) + [[ -n "$draft_tag" ]] && version=$(max_version "$version" "$draft_tag") + tag="v$version" + + notes=$(generate_notes "$tag" "$prev_tag") + + if [[ -n "$draft_tag" ]]; then + out=$(gh release edit "$draft_tag" --draft --tag "$tag" --target "$TARGET_SHA" --title "$tag" --notes "$notes") + echo "Updated draft $draft_tag → $tag (bump=$bump, prev=${prev_tag:-none}, target=$TARGET_SHA)" + else + out=$(gh release create "$tag" --draft --target "$TARGET_SHA" --title "$tag" --notes "$notes") + echo "Created draft $tag (bump=$bump, prev=${prev_tag:-none}, target=$TARGET_SHA)" + fi + + # gh release create/edit 는 릴리즈 URL을 출력한다. draft는 untagged-* 주소라 못 얻으면 목록 페이지로 대신한다 + url=$({ grep -E '^https://' <<<"$out" || true; } | tail -1) + notify_slack "$(draft_message "$tag" "$draft_tag" "${url:-https://github.com/$GH_REPO/releases}")" + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### Draft release \`$tag\`" + echo "- bump: \`$bump\` / previous: \`${prev_tag:-none}\` / target: \`$TARGET_SHA\`" + } >>"$GITHUB_STEP_SUMMARY" + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/.github/scripts/release-draft.test.sh b/.github/scripts/release-draft.test.sh new file mode 100644 index 000000000..b2105bbe6 --- /dev/null +++ b/.github/scripts/release-draft.test.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# release-draft.sh 로컬 테스트: bash .github/scripts/release-draft.test.sh +# gh를 스텁으로 대체하므로 네트워크/권한 없이 실행된다. +set -uo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-draft.sh" +# shellcheck source=release-draft.sh +source "$SCRIPT" +set +e # source한 스크립트의 errexit 해제 (실패 케이스도 끝까지 확인) + +failures=0 +assert_eq() { + if [[ "$2" == "$3" ]]; then + echo "ok $1" + else + echo "FAIL $1: got '$2', want '$3'" + failures=$((failures + 1)) + fi +} + +echo "# resolve_bump" +assert_eq "hotfix/* → patch" "$(resolve_bump hotfix/auth '🚑 Hotfix')" patch +assert_eq "dev (라벨 없음) → minor" "$(resolve_bump dev '')" minor +assert_eq "dev + 기타 라벨 → minor" "$(resolve_bump dev $'🚀 Release\n✨ Feature')" minor +assert_eq "dev + 💥 Breaking → major" "$(resolve_bump dev $'🚀 Release\n💥 Breaking')" major +assert_eq "dev + 🩹 Patch → patch" "$(resolve_bump dev $'🚀 Release\n🩹 Patch')" patch +assert_eq "💥 Breaking 이 🩹 Patch 보다 우선" "$(resolve_bump dev $'🩹 Patch\n💥 Breaking')" major +assert_eq "release/* → minor" "$(resolve_bump release/v1.3.0 '')" minor +assert_eq "feat/* 직행 → patch" "$(resolve_bump feat/WTH-1-x '✨ Feature')" patch +assert_eq "devtools 는 릴리즈 PR 아님 → patch" "$(resolve_bump devtools '')" patch + +echo "# next_version" +assert_eq "v1.2.3 major" "$(next_version v1.2.3 major)" 2.0.0 +assert_eq "v1.2.3 minor" "$(next_version v1.2.3 minor)" 1.3.0 +assert_eq "v1.2.3 patch" "$(next_version v1.2.3 patch)" 1.2.4 +assert_eq "v 접두사 없음" "$(next_version 1.2.9 patch)" 1.2.10 +assert_eq "태그 없음(v0.0.0 기준) minor" "$(next_version v0.0.0 minor)" 0.1.0 +next_version v1.0.0 huge >/dev/null 2>&1 +assert_eq "알 수 없는 bump 는 실패" "$?" 1 + +echo "# max_version / highest_tag" +assert_eq "숫자 정렬 (1.10.0 > 1.9.0)" "$(max_version v1.10.0 v1.9.0)" 1.10.0 +assert_eq "draft v1.3.0 vs 1.2.1" "$(max_version 1.2.1 v1.3.0)" 1.3.0 +assert_eq "같은 버전" "$(max_version v1.2.1 1.2.1)" 1.2.1 +assert_eq "최고 태그 (숫자 정렬)" "$(printf 'v1.9.0\nv1.10.0\nv1.2.0\n' | highest_tag)" v1.10.0 +assert_eq "semver 외/prerelease 태그 제외" "$(printf 'v2.0.0-rc.1\nlatest\nv1.1.0\n' | highest_tag)" v1.1.0 +assert_eq "태그 없음 → 빈 값" "$(printf '' | highest_tag)" "" + +echo "# main (gh 스텁)" +STUB_DIR=$(mktemp -d) +trap 'rm -rf "$STUB_DIR"' EXIT +cat >"$STUB_DIR/gh" <<'EOF' +#!/usr/bin/env bash +echo "gh $*" >>"$GH_LOG" +case "$1 $2" in + "release list") if [[ "$*" == *--exclude-drafts* ]]; then printf '%s' "$PUBLISHED"; else printf '%s' "$DRAFTS"; fi ;; + "api repos/"*) echo "NOTES" ;; + "release create" | "release edit") [[ "${STUB_NO_URL:-}" == 1 ]] || echo "https://github.com/o/r/releases/tag/untagged-1" ;; +esac +EOF +chmod +x "$STUB_DIR/gh" +# curl 스텁: -d 로 넘어온 payload를 기록하고, CURL_FAIL=1 이면 실패 +cat >"$STUB_DIR/curl" <<'EOF' +#!/usr/bin/env bash +while [[ $# -gt 0 ]]; do + [[ "$1" == "-d" ]] && echo "curl $2" >>"$GH_LOG" + shift +done +[[ "${CURL_FAIL:-}" != 1 ]] +EOF +chmod +x "$STUB_DIR/curl" + +# run_main KEY=VALUE... → 실행된 gh release create/edit 명령 (실패 시 EXIT) +run_main() { + ( + export PATH="$STUB_DIR:$PATH" GH_LOG="$STUB_DIR/log" GH_REPO=o/r TARGET_SHA=abc + export PUBLISHED='' DRAFTS='' BUMP_INPUT='' HEAD_REF='' PR_LABELS='[]' + export SLACK_WEBHOOK_URL='' CURL_FAIL='' STUB_NO_URL='' + # shellcheck disable=SC2163 # KEY=VALUE 인자를 그대로 export + export "$@" + : >"$GH_LOG" + bash "$SCRIPT" >/dev/null 2>&1 || echo EXIT + grep -E '^gh release (create|edit)' "$GH_LOG" + ) +} + +assert_eq "hotfix, draft 없음 → v1.2.1 생성" \ + "$(run_main PUBLISHED=$'v1.2.0\nv1.1.1' HEAD_REF=hotfix/a PR_LABELS='["🚑 Hotfix"]')" \ + "gh release create v1.2.1 --draft --target abc --title v1.2.1 --notes NOTES" +assert_eq "hotfix draft 위에 릴리즈 PR → v1.3.0 으로 갱신" \ + "$(run_main PUBLISHED=v1.2.0 DRAFTS=v1.2.1 HEAD_REF=dev PR_LABELS='["🚀 Release"]')" \ + "gh release edit v1.2.1 --draft --tag v1.3.0 --target abc --title v1.3.0 --notes NOTES" +assert_eq "minor draft 위에 hotfix → v1.3.0 유지" \ + "$(run_main PUBLISHED=v1.2.0 DRAFTS=v1.3.0 HEAD_REF=hotfix/b)" \ + "gh release edit v1.3.0 --draft --tag v1.3.0 --target abc --title v1.3.0 --notes NOTES" +assert_eq "workflow_dispatch major (라벨 null)" \ + "$(run_main PUBLISHED=v1.2.0 BUMP_INPUT=major PR_LABELS=null)" \ + "gh release create v2.0.0 --draft --target abc --title v2.0.0 --notes NOTES" +assert_eq "낮은 버전의 오래된 draft 흡수" \ + "$(run_main PUBLISHED=v1.2.0 DRAFTS=v1.1.2 HEAD_REF=dev)" \ + "gh release edit v1.1.2 --draft --tag v1.3.0 --target abc --title v1.3.0 --notes NOTES" +assert_eq "첫 릴리즈 → v0.1.0" \ + "$(run_main HEAD_REF=dev)" \ + "gh release create v0.1.0 --draft --target abc --title v0.1.0 --notes NOTES" +assert_eq "잘못된 bump → 릴리즈 호출 없이 실패" \ + "$(run_main PUBLISHED=v1.0.0 BUMP_INPUT=huge)" \ + "EXIT" + +run_main PUBLISHED=v1.2.0 HEAD_REF=dev >/dev/null +assert_eq "generate-notes 에 직전 태그 전달" \ + "$(grep -c 'previous_tag_name=v1.2.0' "$STUB_DIR/log")" 1 +run_main HEAD_REF=dev >/dev/null +assert_eq "태그 없으면 previous_tag_name 생략" \ + "$(grep -c 'previous_tag_name' "$STUB_DIR/log")" 0 + +echo "# slack 알림" +# slack_text KEY=VALUE... → 전송된 Slack 메시지 text (전송 안 했으면 빈 값, 스크립트 실패 시 EXIT) +slack_text() { + run_main "$@" >/dev/null + grep '^curl ' "$STUB_DIR/log" | sed 's/^curl //' | jq -r .text +} +WEBHOOK=SLACK_WEBHOOK_URL=https://hooks.example/x + +assert_eq "시크릿 없으면 전송 안 함" \ + "$(run_main PUBLISHED=v1.2.0 HEAD_REF=dev >/dev/null; grep -c '^curl ' "$STUB_DIR/log")" 0 +assert_eq "draft 생성 메시지" \ + "$(slack_text $WEBHOOK PUBLISHED=v1.2.0 HEAD_REF=dev)" \ + $'🚀 *[r]* v1.3.0 draft 생성\n확인 후 Publish: ' +assert_eq "draft URL 을 못 얻으면 Releases 목록으로 폴백" \ + "$(slack_text $WEBHOOK STUB_NO_URL=1 PUBLISHED=v1.2.0 HEAD_REF=dev | tail -1)" \ + '확인 후 Publish: ' +assert_eq "draft 버전이 바뀌면 이전 → 새 태그 표시" \ + "$(slack_text $WEBHOOK PUBLISHED=v1.2.0 DRAFTS=v1.2.1 HEAD_REF=dev | head -1)" \ + '🚀 *[r]* v1.3.0 draft 갱신 (v1.2.1 → v1.3.0)' +assert_eq "같은 버전 draft 갱신" \ + "$(slack_text $WEBHOOK PUBLISHED=v1.2.0 DRAFTS=v1.3.0 HEAD_REF=hotfix/b | head -1)" \ + '🚀 *[r]* v1.3.0 draft 갱신' +assert_eq "Slack 전송 실패해도 draft 생성은 성공" \ + "$(run_main $WEBHOOK CURL_FAIL=1 PUBLISHED=v1.2.0 HEAD_REF=dev)" \ + "gh release create v1.3.0 --draft --target abc --title v1.3.0 --notes NOTES" + +echo +if [[ $failures -gt 0 ]]; then + echo "$failures test(s) failed" + exit 1 +fi +echo "all tests passed" diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml new file mode 100644 index 000000000..c04e34571 --- /dev/null +++ b/.github/workflows/backmerge.yml @@ -0,0 +1,79 @@ +name: Backmerge + +# hotfix처럼 dev를 거치지 않고 main에 들어간 변경을 dev로 되돌려 보내는 PR을 연다. +# main을 dev에 머지해도 dev 트리가 그대로면(일반 dev → main 릴리즈 머지) 건너뛴다. +# 커밋 수가 아닌 머지 결과로 판정해야 릴리즈 머지 커밋에서만 생긴 충돌 해결 변경도 놓치지 않는다. +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: backmerge + cancel-in-progress: false + +env: + # 조직 설정상 GITHUB_TOKEN으로 PR 생성이 막혀 있으면 BACKMERGE_TOKEN(PAT/App 토큰)을 등록해 사용 + BACKMERGE_TOKEN: ${{ secrets.BACKMERGE_TOKEN || github.token }} + +jobs: + backmerge: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ env.BACKMERGE_TOKEN }} + + - name: Open backmerge PR + env: + GH_TOKEN: ${{ env.BACKMERGE_TOKEN }} + run: | + set -euo pipefail + + # merge-tree는 충돌 시 exit 1 → 백머지 필요로 본다 + if merged=$(git merge-tree --write-tree origin/dev "$GITHUB_SHA") && + [[ "$(head -1 <<<"$merged")" == "$(git rev-parse 'origin/dev^{tree}')" ]]; then + echo "Merging main into dev changes nothing. Skip." + exit 0 + fi + + # 이미 열린 백머지 PR이 있으면 그 브랜치를 fast-forward (충돌 해결 커밋이 있으면 코멘트만 남김) + # fork PR이 같은 이름의 브랜치를 쓰면 origin에 push해도 그 PR은 갱신되지 않으므로 같은 저장소 PR만 본다 + existing=$(gh pr list --base dev --state open --limit 100 --json headRefName,number,isCrossRepository \ + -q '[.[] | select((.isCrossRepository | not) and (.headRefName | startswith("chore/backmerge-")))][0] // empty | "\(.number) \(.headRefName)"') + if [[ -n "$existing" ]]; then + read -r pr_number branch <<<"$existing" + if git push origin "$GITHUB_SHA:refs/heads/$branch"; then + echo "Fast-forwarded $branch (#$pr_number) to ${GITHUB_SHA::7}" + else + gh pr comment "$pr_number" --body "main에 새 커밋(\`${GITHUB_SHA::7}\`)이 추가됐지만 이 브랜치로 fast-forward 할 수 없습니다. \`git merge origin/main\`으로 직접 반영해 주세요." + fi + exit 0 + fi + + # main을 head로 직접 PR을 열면 웹 충돌 해결 시 dev 내용이 main에 커밋되므로 별도 브랜치를 쓴다 + branch="chore/backmerge-${GITHUB_SHA::7}" + git push origin "$GITHUB_SHA:refs/heads/$branch" + + commits=$(git log --no-merges --format='- %s (%h)' "origin/dev..$GITHUB_SHA") + # 머지 커밋의 충돌 해결분만 빠진 경우엔 non-merge 커밋이 없으므로 머지 커밋을 보여준다 + [[ -z "$commits" ]] && commits=$(git log --merges --format='- %s (%h)' "origin/dev..$GITHUB_SHA") + body=$(cat < 충돌이 있으면 이 브랜치에서 \`git merge origin/dev\`로 해결하고, **Merge commit** 방식으로 머지해 주세요. + EOF + ) + gh pr create --base dev --head "$branch" \ + --title "chore: backmerge main into dev (${GITHUB_SHA::7})" \ + --body "$body" diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index a89d0ac5d..44d36bf84 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -11,7 +11,27 @@ env: IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} jobs: + # main에 머지되지 않은 커밋(예: dev를 타깃으로 publish된 릴리즈)이 운영에 나가지 않도록 차단 + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify release commit is on main + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Release $TAG_NAME ($GITHUB_SHA) is not on main. Aborting prod deploy." + exit 1 + fi + build: + needs: verify runs-on: ubuntu-latest steps: @@ -89,3 +109,44 @@ jobs: DOMAIN="${{ secrets.PROD_DOMAIN }}" \ DEPLOY_DIR="$DEPLOY_DIR" \ "$DEPLOY_DIR/scripts/deploy.sh" + + # 어느 단계에서 끝나든 결과를 Slack으로 알린다 (SLACK_WEBHOOK_URL 미설정 시 건너뜀) + notify: + needs: [verify, build, deploy] + if: always() + runs-on: ubuntu-latest + + steps: + - name: Notify Slack + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + TAG_NAME: ${{ github.event.release.tag_name }} + RELEASE_URL: ${{ github.event.release.html_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + VERIFY_RESULT: ${{ needs.verify.result }} + BUILD_RESULT: ${{ needs.build.result }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + run: | + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "SLACK_WEBHOOK_URL is not set. Skip." + exit 0 + fi + + repo=${GITHUB_REPOSITORY#*/} + if [[ "$DEPLOY_RESULT" == "success" ]]; then + text="✅ *[$repo]* $TAG_NAME 운영 배포 완료 · <$RELEASE_URL|릴리즈 노트>" + else + # 처음으로 성공하지 못한 단계 (이후 단계는 skipped) + stage=deploy result=$DEPLOY_RESULT + for s in verify:$VERIFY_RESULT build:$BUILD_RESULT; do + if [[ "${s#*:}" != "success" ]]; then + stage=${s%%:*} result=${s#*:} + break + fi + done + text="❌ *[$repo]* $TAG_NAME 운영 배포 실패 ($stage: $result) · <$RUN_URL|Actions 로그>" + fi + + payload=$(jq -nc --arg text "$text" '{text: $text}') + curl -fsS --max-time 10 -H 'Content-Type: application/json' -d "$payload" "$SLACK_WEBHOOK_URL" >/dev/null \ + || echo "::warning::Slack 알림 전송 실패" diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..b98623542 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,20 @@ +name: PR Labeler + +# fork PR에도 라벨을 쓰려면 쓰기 토큰이 필요하다. labeler는 PR 코드를 checkout/실행하지 않으므로 pull_request_target이 안전하다 +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v6 + with: + configuration-path: .github/labeler.yml + # 수동으로 붙인 라벨(💥 Breaking, 🩹 Patch 등)은 유지 + sync-labels: false diff --git a/.github/workflows/release-create.yml b/.github/workflows/release-create.yml new file mode 100644 index 000000000..38807151f --- /dev/null +++ b/.github/workflows/release-create.yml @@ -0,0 +1,47 @@ +name: Release Create + +# main 머지 시점에 draft 릴리즈를 만든다. Publish는 사람이 하고, publish 이벤트로 deploy-prod.yml이 실행된다. +on: + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + bump: + description: "버전 증가 단위" + type: choice + options: [minor, patch, major] + default: minor + +permissions: + contents: write + pull-requests: read + +# 연속 머지 시 draft 갱신이 겹치지 않도록 직렬화 +concurrency: + group: release-create + cancel-in-progress: false + +jobs: + draft: + runs-on: ubuntu-latest + if: > + (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + + - name: Create or update draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TARGET_SHA: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + BUMP_INPUT: ${{ inputs.bump }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: bash .github/scripts/release-draft.sh diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 5a334b6d0..000000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Release Drafter - -on: - push: - branches: [dev] - workflow_dispatch: - -permissions: - contents: write - pull-requests: read - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - with: - config-name: release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}