From 77bf6297554f1bd91206ebd39e416a1f5f8cea9c Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 4 Sep 2026 13:38:17 +0900 Subject: [PATCH 1/5] =?UTF-8?q?ci:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=95=A1=EC=85=98=EC=9D=84=20=EC=84=9C?= =?UTF-8?q?=EB=AA=85=20=EA=B2=80=EC=A6=9D=20=ED=9B=84=20=EC=8A=B9=EA=B2=A9?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=9B=90=EC=9E=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/actions/docker-build-push/action.yml | 284 ++++++++++++++++-- .github/workflows/deploy-dev.yml | 2 +- .../workflows/deploy_release_applications.yml | 2 +- 3 files changed, 253 insertions(+), 35 deletions(-) diff --git a/.github/actions/docker-build-push/action.yml b/.github/actions/docker-build-push/action.yml index 5a5524c..cd26c0d 100644 --- a/.github/actions/docker-build-push/action.yml +++ b/.github/actions/docker-build-push/action.yml @@ -1,5 +1,5 @@ name: 'Docker Build and Push' -description: 'Docker 이미지를 빌드하고 Private Registry에 푸시하는 Composite Action' +description: 'Docker 이미지를 immutable 태그로 빌드·푸시하고, 서명·검증을 통과한 digest 만 채널 태그로 승격하는 Composite Action' inputs: registry-url: @@ -27,15 +27,27 @@ inputs: required: false default: 'linux/arm64' image-tag: - description: '메인 이미지 태그' + description: '빌드 시 push 하는 immutable 이미지 태그 (정확히 1개)' required: true - additional-tags: - description: '추가 태그들 (쉼표로 구분)' + promote-tags: + description: '서명·검증을 통과한 digest 에 부착할 채널 태그들 (쉼표로 구분). 빌드 시 push 하지 않고 검증 후 승격한다' required: false default: '' + source-sha: + description: '이미지에 기록할 소스 커밋 SHA (GIT_COMMIT build-arg)' + required: false + default: ${{ github.sha }} + source-ref: + description: '이미지에 기록할 소스 ref (GIT_BRANCH build-arg)' + required: false + default: ${{ github.ref_name }} cache-scope: description: 'GHA 캐시 scope' required: true + cache-export: + description: '빌드 캐시 export 여부. 승격 이후에만 실행되고 실패해도 action 을 실패시키지 않는다' + required: false + default: 'true' build-args: description: '추가 Docker build arguments' required: false @@ -72,6 +84,10 @@ inputs: description: 'cosign 비밀키 파일 경로' required: false default: '' + cosign-public-key-path: + description: 'cosign 공개키 파일 경로. 비어 있으면 비밀키에서 파생한다' + required: false + default: '' cosign-password: description: 'cosign 키 패스워드' required: false @@ -86,11 +102,20 @@ outputs: description: '푸시된 이미지의 digest' value: ${{ steps.build-push.outputs.digest }} image-uri: - description: '푸시된 이미지의 전체 URI' + description: '푸시된 immutable 이미지의 전체 URI' value: ${{ steps.tags.outputs.primary }} image-tags: - description: '생성된 전체 이미지 태그 목록' + description: 'immutable 태그와 승격된 채널 태그의 전체 URI 목록' value: ${{ steps.tags.outputs.tags }} + promoted-tags: + description: '승격이 완료된 채널 태그들 (쉼표로 구분)' + value: ${{ steps.promote.outputs.promoted-tags }} + previous-digest: + description: '승격 직전 첫 채널 태그가 가리키던 digest (없으면 none). 롤백 기준값' + value: ${{ steps.previous.outputs.previous-digest }} + promotion-verified: + description: '승격된 채널 태그의 digest 동등성과 서명 검증 결과' + value: ${{ steps.verify-promoted.outputs.promotion-verified }} runs: using: 'composite' @@ -108,29 +133,65 @@ runs: - name: Generate build timestamp id: build-time shell: bash - run: echo "time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + run: echo "time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" - name: Prepare image tags id: tags shell: bash + env: + REGISTRY: ${{ inputs.registry-url }} + IMAGE: ${{ inputs.image-name }} + MAIN_TAG: ${{ inputs.image-tag }} + PROMOTE_TAGS: ${{ inputs.promote-tags }} + run: | + set -euo pipefail + tag_pattern='^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' + [[ "$MAIN_TAG" =~ $tag_pattern ]] || { + echo "::error::image-tag is not a valid image tag: ${MAIN_TAG}" + exit 1 + } + primary="${REGISTRY}/${IMAGE}:${MAIN_TAG}" + tags="$primary" + promote_list="" + promote_count=0 + IFS=',' read -ra raw_tags <<< "$PROMOTE_TAGS" + for tag in "${raw_tags[@]}"; do + tag="$(echo "$tag" | xargs)" + [[ -n "$tag" ]] || continue + [[ "$tag" =~ $tag_pattern ]] || { + echo "::error::promote-tags contains an invalid image tag: ${tag}" + exit 1 + } + [[ "$tag" != "$MAIN_TAG" ]] || { + echo "::error::promote-tags must not repeat the immutable image-tag: ${tag}" + exit 1 + } + promote_list+="${tag}"$'\n' + tags+=$'\n'"${REGISTRY}/${IMAGE}:${tag}" + promote_count=$((promote_count + 1)) + done + { + echo "primary=${primary}" + echo "promote-count=${promote_count}" + echo "promote-list<> "$GITHUB_OUTPUT" + + - name: Prepare build cache references + id: cache + shell: bash + env: + CACHE_SCOPE: ${{ inputs.cache-scope }} run: | - REGISTRY="${{ inputs.registry-url }}" - IMAGE="${{ inputs.image-name }}" - MAIN_TAG="${{ inputs.image-tag }}" - PRIMARY="${REGISTRY}/${IMAGE}:${MAIN_TAG}" - echo "primary=${PRIMARY}" >> $GITHUB_OUTPUT - TAGS="${PRIMARY}" - if [ -n "${{ inputs.additional-tags }}" ]; then - IFS=',' read -ra EXTRA_TAGS <<< "${{ inputs.additional-tags }}" - for tag in "${EXTRA_TAGS[@]}"; do - tag=$(echo "$tag" | xargs) - TAGS="${TAGS} - ${REGISTRY}/${IMAGE}:${tag}" - done - fi - echo "tags<> $GITHUB_OUTPUT - echo "$TAGS" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + set -euo pipefail + { + echo "from=type=gha,scope=${CACHE_SCOPE}" + echo "to=type=gha,scope=${CACHE_SCOPE},mode=max" + } >> "$GITHUB_OUTPUT" - name: Docker metadata id: meta @@ -146,6 +207,7 @@ runs: org.opencontainers.image.authors=${{ inputs.image-authors }} org.opencontainers.image.licenses=${{ inputs.image-licenses }} org.opencontainers.image.documentation=${{ inputs.image-documentation }} + org.opencontainers.image.revision=${{ inputs.source-sha }} annotations: | org.opencontainers.image.title=${{ inputs.image-title || inputs.image-name }} org.opencontainers.image.description=${{ inputs.image-description }} @@ -153,10 +215,13 @@ runs: org.opencontainers.image.authors=${{ inputs.image-authors }} org.opencontainers.image.licenses=${{ inputs.image-licenses }} org.opencontainers.image.documentation=${{ inputs.image-documentation }} + org.opencontainers.image.revision=${{ inputs.source-sha }} env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - - name: Build and push Docker image + # immutable 태그 1개만 push 한다. 채널 태그는 서명·검증 이후 승격 단계에서만 부착되고, + # 캐시 export 는 별도 단계로 분리해 export 실패가 push 결과에 섞이지 않게 한다. + - name: build and push immutable image id: build-push uses: docker/build-push-action@v7 with: @@ -164,30 +229,183 @@ runs: file: ${{ inputs.dockerfile }} platforms: ${{ inputs.platforms }} push: true - tags: ${{ steps.tags.outputs.tags }} + tags: ${{ steps.tags.outputs.primary }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} build-args: | - GIT_COMMIT=${{ github.sha }} - GIT_BRANCH=${{ github.ref_name }} + GIT_COMMIT=${{ inputs.source-sha }} + GIT_BRANCH=${{ inputs.source-ref }} BUILD_TIME=${{ steps.build-time.outputs.time }} ${{ inputs.build-args }} - cache-from: type=gha,scope=${{ inputs.cache-scope }} - cache-to: type=gha,scope=${{ inputs.cache-scope }},mode=max + cache-from: ${{ steps.cache.outputs.from }} secrets: ${{ inputs.build-secrets }} + - name: inspect pushed digest + shell: bash + env: + IMAGE: ${{ inputs.registry-url }}/${{ inputs.image-name }} + DIGEST: ${{ steps.build-push.outputs.digest }} + PRIMARY_REF: ${{ steps.tags.outputs.primary }} + run: | + set -euo pipefail + [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "::error::build did not report a valid image digest: ${DIGEST}" + exit 1 + } + docker buildx imagetools inspect "${IMAGE}@${DIGEST}" >/dev/null + pushed_digest="$(docker buildx imagetools inspect "$PRIMARY_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')" + [[ "$pushed_digest" == "$DIGEST" ]] || { + echo "::error::immutable tag ${PRIMARY_REF} points to ${pushed_digest}, expected ${DIGEST}" + exit 1 + } + echo "pushed ${PRIMARY_REF} -> ${DIGEST}" + - name: Install Cosign if: inputs.sign-image == 'true' uses: sigstore/cosign-installer@v3 with: cosign-release: 'v2.2.4' - - name: Sign Image with Cosign + - name: sign image with cosign if: inputs.sign-image == 'true' shell: bash env: COSIGN_PASSWORD: ${{ inputs.cosign-password }} + COSIGN_KEY: ${{ github.workspace }}/${{ inputs.cosign-key-path }} + IMAGE: ${{ inputs.registry-url }}/${{ inputs.image-name }} + DIGEST: ${{ steps.build-push.outputs.digest }} run: | - cosign sign --key "${{ github.workspace }}/${{ inputs.cosign-key-path }}" \ + set -euo pipefail + cosign sign --key "$COSIGN_KEY" \ --tlog-upload=false \ - "${{ inputs.registry-url }}/${{ inputs.image-name }}@${{ steps.build-push.outputs.digest }}" + "${IMAGE}@${DIGEST}" + + - name: verify image signature + if: inputs.sign-image == 'true' + shell: bash + env: + COSIGN_PASSWORD: ${{ inputs.cosign-password }} + COSIGN_KEY: ${{ github.workspace }}/${{ inputs.cosign-key-path }} + COSIGN_PUBLIC_KEY_PATH: ${{ inputs.cosign-public-key-path }} + IMAGE: ${{ inputs.registry-url }}/${{ inputs.image-name }} + DIGEST: ${{ steps.build-push.outputs.digest }} + run: | + set -euo pipefail + # 서명은 tlog 없이 했으므로 검증도 tlog 를 무시해야 한다 (cosign 2.x) + if [[ -n "$COSIGN_PUBLIC_KEY_PATH" ]]; then + pub="${GITHUB_WORKSPACE}/${COSIGN_PUBLIC_KEY_PATH}" + [[ -f "$pub" ]] || { + echo "::error::cosign public key not found: ${pub}" + exit 1 + } + else + pub="${RUNNER_TEMP}/cosign.pub" + cosign public-key --key "$COSIGN_KEY" > "$pub" + fi + cosign verify --key "$pub" --insecure-ignore-tlog=true \ + --output text \ + "${IMAGE}@${DIGEST}" + + - name: record previous channel digests + id: previous + if: steps.tags.outputs.promote-count != '0' + shell: bash + env: + IMAGE: ${{ inputs.registry-url }}/${{ inputs.image-name }} + PROMOTE_TAG_LIST: ${{ steps.tags.outputs.promote-list }} + run: | + set -euo pipefail + first_previous="" + while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + previous="$(docker buildx imagetools inspect "${IMAGE}:${tag}" --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"' || true)" + [[ -n "$previous" ]] || previous="none" + [[ -n "$first_previous" ]] || first_previous="$previous" + echo "previous ${IMAGE}:${tag} -> ${previous}" + done <<< "$PROMOTE_TAG_LIST" + echo "previous-digest=${first_previous}" >> "$GITHUB_OUTPUT" + + # 재빌드 없이 검증된 digest 에 채널 태그만 부착한다. digest 가 달라지면 즉시 실패한다 (불변조건 I3). + - name: promote verified digest to channel tags + id: promote + if: steps.tags.outputs.promote-count != '0' + shell: bash + env: + IMAGE: ${{ inputs.registry-url }}/${{ inputs.image-name }} + DIGEST: ${{ steps.build-push.outputs.digest }} + PROMOTE_TAG_LIST: ${{ steps.tags.outputs.promote-list }} + run: | + set -euo pipefail + promoted="" + while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + docker buildx imagetools create --tag "${IMAGE}:${tag}" "${IMAGE}@${DIGEST}" + promoted_digest="$(docker buildx imagetools inspect "${IMAGE}:${tag}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + [[ "$promoted_digest" == "$DIGEST" ]] || { + echo "::error::promoted tag ${tag} points to ${promoted_digest}, expected ${DIGEST}" + exit 1 + } + promoted+="${promoted:+,}${tag}" + echo "promoted ${IMAGE}:${tag} -> ${DIGEST}" + done <<< "$PROMOTE_TAG_LIST" + echo "promoted-tags=${promoted}" >> "$GITHUB_OUTPUT" + + - name: verify promoted tags + id: verify-promoted + if: steps.tags.outputs.promote-count != '0' + shell: bash + env: + SIGN_IMAGE: ${{ inputs.sign-image }} + COSIGN_PASSWORD: ${{ inputs.cosign-password }} + COSIGN_KEY: ${{ github.workspace }}/${{ inputs.cosign-key-path }} + COSIGN_PUBLIC_KEY_PATH: ${{ inputs.cosign-public-key-path }} + IMAGE: ${{ inputs.registry-url }}/${{ inputs.image-name }} + DIGEST: ${{ steps.build-push.outputs.digest }} + PROMOTE_TAG_LIST: ${{ steps.tags.outputs.promote-list }} + run: | + set -euo pipefail + pub="" + if [[ "$SIGN_IMAGE" == "true" ]]; then + if [[ -n "$COSIGN_PUBLIC_KEY_PATH" ]]; then + pub="${GITHUB_WORKSPACE}/${COSIGN_PUBLIC_KEY_PATH}" + else + pub="${RUNNER_TEMP}/cosign.pub" + cosign public-key --key "$COSIGN_KEY" > "$pub" + fi + fi + while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + promoted_digest="$(docker buildx imagetools inspect "${IMAGE}:${tag}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + [[ "$promoted_digest" == "$DIGEST" ]] || { + echo "::error::promoted tag ${tag} points to ${promoted_digest}, expected ${DIGEST}" + exit 1 + } + if [[ -n "$pub" ]]; then + cosign verify --key "$pub" --insecure-ignore-tlog=true \ + --output text \ + "${IMAGE}:${tag}" + fi + echo "verified ${IMAGE}:${tag} -> ${DIGEST}" + done <<< "$PROMOTE_TAG_LIST" + echo "promotion-verified=true" >> "$GITHUB_OUTPUT" + + # 승격 이후에만 실행한다. 같은 builder 의 로컬 캐시를 재사용하므로 재빌드 없이 export 만 수행하며, + # 실패해도 이미 승격된 결과에는 영향을 주지 않는다 (불변조건 I4). + - name: export build cache + if: inputs.cache-export == 'true' + continue-on-error: true + uses: docker/build-push-action@v7 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + push: false + outputs: type=cacheonly + build-args: | + GIT_COMMIT=${{ inputs.source-sha }} + GIT_BRANCH=${{ inputs.source-ref }} + BUILD_TIME=${{ steps.build-time.outputs.time }} + ${{ inputs.build-args }} + cache-from: ${{ steps.cache.outputs.from }} + cache-to: ${{ steps.cache.outputs.to }} + secrets: ${{ inputs.build-secrets }} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index e699eec..c83a5b1 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -50,7 +50,7 @@ jobs: registry-password: ${{ secrets.REGISTRY_PASSWORD }} image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ steps.version.outputs.image-tag }} - additional-tags: dashboard_latest_development + promote-tags: dashboard_latest_development dockerfile: Dockerfile context: . platforms: linux/arm64 diff --git a/.github/workflows/deploy_release_applications.yml b/.github/workflows/deploy_release_applications.yml index b9e7a4f..19f1d7d 100644 --- a/.github/workflows/deploy_release_applications.yml +++ b/.github/workflows/deploy_release_applications.yml @@ -211,7 +211,7 @@ jobs: registry-password: ${{ secrets.REGISTRY_PASSWORD }} image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ needs.release-gate.outputs.image-tag }} - additional-tags: ${{ needs.release-gate.outputs.deployment-source == 'release-pr' && 'dashboard_latest_production' || '' }} + promote-tags: ${{ needs.release-gate.outputs.deployment-source == 'release-pr' && 'dashboard_latest_production' || '' }} dockerfile: Dockerfile context: . platforms: linux/arm64 From 8385220ece0344e0f298548da7bb2806c7c817c8 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 4 Sep 2026 13:39:04 +0900 Subject: [PATCH 2/5] =?UTF-8?q?ci:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=EC=99=80?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=20=EB=B0=B0=ED=8F=AC=EB=A5=BC=20=EC=8A=B9?= =?UTF-8?q?=EA=B2=A9=20=EA=B8=B0=EB=B0=98=20=EB=B9=8C=EB=93=9C=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-dev.yml | 4 ++ .../workflows/deploy_release_applications.yml | 62 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index c83a5b1..d91a873 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -51,10 +51,13 @@ jobs: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ steps.version.outputs.image-tag }} promote-tags: dashboard_latest_development + source-sha: ${{ env.SOURCE_SHA }} + source-ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }} dockerfile: Dockerfile context: . platforms: linux/arm64 cache-scope: dashboard-dev + cache-export: 'true' image-title: "BottleNote Admin Dashboard (Development)" image-description: "BottleNote Admin Dashboard Frontend" image-vendor: "BottleNote" @@ -65,4 +68,5 @@ jobs: age_key=${{ secrets.SOPS_AGE_SECRET_KEY }} sign-image: 'true' cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' + cosign-public-key-path: 'git.environment-variables/storage/docker-registry/cosign.pub' cosign-password: ${{ secrets.COSIGN_PASSWORD }} diff --git a/.github/workflows/deploy_release_applications.yml b/.github/workflows/deploy_release_applications.yml index 19f1d7d..0b3e512 100644 --- a/.github/workflows/deploy_release_applications.yml +++ b/.github/workflows/deploy_release_applications.yml @@ -51,6 +51,7 @@ jobs: release-key: ${{ steps.release.outputs.release-key }} image-tag: ${{ steps.release.outputs.image-tag }} source-sha: ${{ steps.release.outputs.source-sha }} + source-ref: ${{ steps.release.outputs.source-ref }} deployment-source: ${{ steps.release.outputs.deployment-source }} steps: - name: checkout repository @@ -81,6 +82,7 @@ jobs: release_key="$version" image_tag="dashboard_${version}" deployment_source="github-release" + source_ref="$RELEASE_TAG" git fetch --tags origin git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null || { @@ -149,6 +151,7 @@ jobs: echo "::error::standard source_sha is not contained in origin/main: $source_sha" exit 1 } + source_ref="main" ;; hotfix) [[ "$CANDIDATE_SHA_INPUT" =~ ^[0-9a-f]{40}$ && "$BASE_SOURCE_SHA_INPUT" =~ ^[0-9a-f]{40}$ ]] || { @@ -163,6 +166,7 @@ jobs: echo "::error::hotfix deployment source is not the verified base-plus-candidate merge" exit 1 } + source_ref="hotfixes/${release_key}" ;; *) echo "::error::release_type must be standard or hotfix" @@ -176,6 +180,7 @@ jobs: echo "release-key=$release_key" echo "image-tag=$image_tag" echo "source-sha=$source_sha" + echo "source-ref=$source_ref" echo "deployment-source=$deployment_source" } >> "$GITHUB_OUTPUT" @@ -186,6 +191,7 @@ jobs: [[ "$deployment_source" != "release-pr" ]] || echo "- Release type: \`${release_type}\`" echo "- Image tag: \`${image_tag}\`" echo "- Source SHA: \`${source_sha}\`" + echo "- Source ref: \`${source_ref}\`" } >> "$GITHUB_STEP_SUMMARY" build-dashboard-image: @@ -194,6 +200,8 @@ jobs: runs-on: ubuntu-24.04-arm outputs: image-digest: ${{ steps.build.outputs.image-digest }} + promoted-tags: ${{ steps.build.outputs.promoted-tags }} + previous-digest: ${{ steps.build.outputs.previous-digest }} steps: - name: checkout release source with submodules uses: actions/checkout@v7 @@ -202,6 +210,14 @@ jobs: submodules: true token: ${{ secrets.GIT_ACCESS_TOKEN }} + # 릴리즈 소스(특히 핫픽스)는 과거 커밋이라 구 action 스냅샷을 들고 온다. + # 원자화된 action 이 항상 쓰이도록 main 의 action 디렉터리로 덮어쓴다. + - name: refresh docker build action from main + run: | + set -euo pipefail + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + git checkout origin/main -- .github/actions/docker-build-push + - name: build and push docker image id: build uses: ./.github/actions/docker-build-push @@ -212,10 +228,13 @@ jobs: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ needs.release-gate.outputs.image-tag }} promote-tags: ${{ needs.release-gate.outputs.deployment-source == 'release-pr' && 'dashboard_latest_production' || '' }} + source-sha: ${{ needs.release-gate.outputs.source-sha }} + source-ref: ${{ needs.release-gate.outputs.source-ref }} dockerfile: Dockerfile context: . platforms: linux/arm64 cache-scope: dashboard-prod + cache-export: 'false' image-title: "BottleNote Admin Dashboard" image-description: "BottleNote Admin Dashboard Frontend" image-vendor: "BottleNote" @@ -226,6 +245,7 @@ jobs: age_key=${{ secrets.SOPS_AGE_SECRET_KEY }} sign-image: 'true' cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' + cosign-public-key-path: 'git.environment-variables/storage/docker-registry/cosign.pub' cosign-password: ${{ secrets.COSIGN_PASSWORD }} handoff-to-image-updater: @@ -235,13 +255,49 @@ jobs: - release-gate - build-dashboard-image runs-on: ubuntu-latest + timeout-minutes: 5 + env: + CHANNEL_TAG: dashboard_latest_production steps: - - name: summarize image updater handoff + - name: login to private registry + uses: docker/login-action@v4 + with: + registry: ${{ secrets.REGISTRY_ADDRESS }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + # 승격된 채널 태그가 build job 이 서명·검증한 digest 를 가리키는지 독립적으로 재확인한다. + - name: verify promoted channel digest + env: + IMAGE: ${{ secrets.REGISTRY_ADDRESS }}/${{ env.IMAGE_NAME }} + EXPECTED_DIGEST: ${{ needs.build-dashboard-image.outputs.image-digest }} + PROMOTED_TAGS: ${{ needs.build-dashboard-image.outputs.promoted-tags }} + PREVIOUS_DIGEST: ${{ needs.build-dashboard-image.outputs.previous-digest }} + RELEASE_KEY: ${{ needs.release-gate.outputs.release-key }} + SOURCE_SHA: ${{ needs.release-gate.outputs.source-sha }} run: | + set -euo pipefail + [[ "$EXPECTED_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "::error::build job did not report a valid image digest: ${EXPECTED_DIGEST}" + exit 1 + } + [[ "$PROMOTED_TAGS" == "$CHANNEL_TAG" ]] || { + echo "::error::build job promoted '${PROMOTED_TAGS}', expected '${CHANNEL_TAG}'" + exit 1 + } + channel_digest="$(docker buildx imagetools inspect "${IMAGE}:${CHANNEL_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + [[ "$channel_digest" == "$EXPECTED_DIGEST" ]] || { + echo "::error::${CHANNEL_TAG} points to ${channel_digest}, expected ${EXPECTED_DIGEST}" + exit 1 + } { echo "## Dashboard image updater handoff" - echo "- Channel: \`dashboard_latest_production\`" - echo "- Digest: \`${{ needs.build-dashboard-image.outputs.image-digest }}\`" + echo "- Release key: \`${RELEASE_KEY}\`" + echo "- Source SHA: \`${SOURCE_SHA}\`" + echo "- Channel: \`${CHANNEL_TAG}\`" + echo "- Digest: \`${EXPECTED_DIGEST}\`" + echo "- Previous digest: \`${PREVIOUS_DIGEST:-none}\`" + echo "- Channel digest re-check: \`passed\`" echo "- GitOps update: \`Argo CD Image Updater\`" } >> "$GITHUB_STEP_SUMMARY" From 26b148def4033820c8ea3213856e958bde649b03 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 4 Sep 2026 13:41:34 +0900 Subject: [PATCH 3/5] =?UTF-8?q?ci:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20PR=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=A5=BC=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EC=A0=84=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release_pr_merged.yml | 276 +++++++----------------- 1 file changed, 72 insertions(+), 204 deletions(-) diff --git a/.github/workflows/release_pr_merged.yml b/.github/workflows/release_pr_merged.yml index 9a22399..d7b682a 100644 --- a/.github/workflows/release_pr_merged.yml +++ b/.github/workflows/release_pr_merged.yml @@ -1,5 +1,6 @@ name: release PR deployment +# 표준 릴리즈 전용 게이트. 핫픽스는 hotfix_pr_merged.yml 이 hotfixes/** 네임스페이스에서 소유한다. on: pull_request_target: types: [closed] @@ -18,10 +19,7 @@ jobs: timeout-minutes: 10 outputs: release-key: ${{ steps.release.outputs.release-key }} - release-type: ${{ steps.release.outputs.release-type }} source-sha: ${{ steps.release.outputs.source-sha }} - candidate-sha: ${{ steps.release.outputs.candidate-sha }} - base-source-sha: ${{ steps.release.outputs.base-source-sha }} release-branch: ${{ steps.release.outputs.release-branch }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -45,19 +43,58 @@ jobs: set -euo pipefail [[ "$PR_BASE_REF" =~ ^releases/([0-9]{4}-[0-9]{2}-[0-9]{2})/([1-9][0-9]*)$ ]] || { - echo "::error::base ref is not a release ref: $PR_BASE_REF" + echo "::error::base ref is not a standard release ref: $PR_BASE_REF" exit 1 } release_date="${BASH_REMATCH[1]}" sequence="${BASH_REMATCH[2]}" release_key="${release_date}/${sequence}" + + # >>> common PR metadata verification (keep byte-identical between release_pr_merged.yml and hotfix_pr_merged.yml) + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { + echo "::error::release PR number is invalid: $PR_NUMBER" + exit 1 + } [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ && "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ && "$PR_MERGE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::release PR contains an invalid commit SHA" exit 1 } pr_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" - release_values="$(PR_JSON="$pr_json" EXPECTED_KEY="$release_key" python3 - <<'PY' + PR_JSON="$pr_json" EXPECTED_NUMBER="$PR_NUMBER" EXPECTED_BASE="$PR_BASE_REF" EXPECTED_HEAD="$PR_HEAD_REF" \ + EXPECTED_HEAD_SHA="$PR_HEAD_SHA" EXPECTED_BASE_SHA="$PR_BASE_SHA" EXPECTED_MERGE_SHA="$PR_MERGE_SHA" \ + REPOSITORY="$REPOSITORY" python3 - <<'PY' + import json + import os + import sys + + pr = json.loads(os.environ['PR_JSON']) + head = pr.get('head') or {} + base = pr.get('base') or {} + errors = [] + if (head.get('repo') or {}).get('full_name') != os.environ['REPOSITORY']: + errors.append('PR head repository is not this repository') + if (base.get('repo') or {}).get('full_name') != os.environ['REPOSITORY']: + errors.append('PR base repository is not this repository') + if pr.get('number') != int(os.environ['EXPECTED_NUMBER']): + errors.append('PR number differs from the event') + if not pr.get('merged'): + errors.append('PR is not merged') + if head.get('ref') != os.environ['EXPECTED_HEAD'] or head.get('sha') != os.environ['EXPECTED_HEAD_SHA']: + errors.append('PR head differs from the event') + if base.get('ref') != os.environ['EXPECTED_BASE'] or base.get('sha') != os.environ['EXPECTED_BASE_SHA']: + errors.append('PR base differs from the event') + if pr.get('merge_commit_sha') != os.environ['EXPECTED_MERGE_SHA']: + errors.append('PR merge SHA differs from the event') + + if errors: + for error in errors: + print(f'::error::{error}', file=sys.stderr) + sys.exit(1) + PY + # <<< common PR metadata verification + + candidate_sha="$(PR_JSON="$pr_json" EXPECTED_KEY="$release_key" python3 - <<'PY' import json import os import re @@ -75,73 +112,32 @@ jobs: candidate_sha = one('release-source-sha', r'[0-9a-f]{40}') key = one('release-key', r'[0-9]{4}-[0-9]{2}-[0-9]{2}/[1-9][0-9]*') service = one('release-service', r'dashboard') - type_values = re.findall(r'', body) - base_keys = re.findall(r'', body) - base_shas = re.findall(r'', body) - if key != os.environ['EXPECTED_KEY'] or service != 'dashboard': print('::error::release PR body markers do not match the release target', file=sys.stderr) sys.exit(1) - if type_values: - if type_values != ['hotfix'] or len(base_keys) != 1 or len(base_shas) != 1: - print('::error::hotfix release markers are missing or duplicated', file=sys.stderr) - sys.exit(1) - release_type = 'hotfix' - base_key = base_keys[0] - base_source_sha = base_shas[0] - else: - if base_keys or base_shas: - print('::error::standard release PR must not contain hotfix base markers', file=sys.stderr) - sys.exit(1) - release_type = 'standard' - base_key = '' - base_source_sha = '' + # releases/** 는 표준 전용이다. 핫픽스 마커가 보이면 잘못된 네임스페이스로 들어온 PR 이므로 거부한다. + hotfix_markers = ('release-type', 'release-base-key', 'release-base-source-sha') + if any(re.search(rf'', body) - keys = re.findall(r'', body) - services = re.findall(r'', body) - hotfix_types = re.findall(r'', body) - if len(sources) != 1 or len(keys) != 1 or services != ['dashboard']: - print('::error::latest successful release PR markers are invalid', file=sys.stderr) - sys.exit(1) - deployed_source = pr.get('merge_commit_sha') if hotfix_types == ['hotfix'] else sources[0] - if keys[0] != os.environ['EXPECTED_KEY'] or deployed_source != os.environ['EXPECTED_SOURCE']: - print('::error::hotfix base is not the latest successful release deployment source', file=sys.stderr) - sys.exit(1) - PY - - successful_candidate_ci="$(gh api "repos/${REPOSITORY}/commits/${PR_HEAD_SHA}/check-runs" \ - --jq '[.check_runs[] | select(.name == "ci" and .status == "completed" and .conclusion == "success")] | length')" - [[ "$successful_candidate_ci" -ge 1 ]] || { - echo "::error::hotfix candidate has no successful ci check" - exit 1 - } - - read -r merge_sha first_parent second_parent extra_parent <<< "$(git rev-list --parents -n 1 "$PR_MERGE_SHA")" - [[ "$merge_sha" == "$PR_MERGE_SHA" && "$first_parent" == "$PR_BASE_SHA" && \ - "$second_parent" == "$PR_HEAD_SHA" && -z "${extra_parent:-}" ]] || { - echo "::error::hotfix release must use a two-parent merge commit with base and reviewed head" - exit 1 - } - expected_merge_tree="$(git merge-tree --write-tree "$PR_BASE_SHA" "$PR_HEAD_SHA")" - [[ "$merge_tree" == "$expected_merge_tree" ]] || { - echo "::error::hotfix merge tree differs from the deterministic base-plus-candidate merge" - exit 1 - } - source_sha="$PR_MERGE_SHA" - fi + [[ "$release_subject" == "release(dashboard): ${release_key}" ]] || { + echo "::error::release commit subject does not match the release key" + exit 1 + } + [[ "$candidate_tree" == "$base_tree" && "$merge_tree" == "$candidate_tree" ]] || { + echo "::error::standard release PR is not tree-empty" + exit 1 + } + source_sha="$candidate_sha" { echo "release-key=${release_key}" - echo "release-type=${release_type}" echo "source-sha=${source_sha}" - echo "candidate-sha=${candidate_sha}" - echo "base-source-sha=${base_source_sha}" echo "release-branch=${PR_BASE_REF}" } >> "$GITHUB_OUTPUT" { echo "## Admin Dashboard release PR verified" echo "- Release key: \`${release_key}\`" - echo "- Release type: \`${release_type}\`" + echo "- Release type: \`standard\`" echo "- Candidate SHA: \`${candidate_sha}\`" echo "- Deployment SHA: \`${source_sha}\`" - [[ -z "$base_key" ]] || echo "- Base release: \`${base_key}\` at \`${base_source_sha}\`" } >> "$GITHUB_STEP_SUMMARY" deploy-release-applications: @@ -325,9 +191,7 @@ jobs: with: source_sha: ${{ needs.verify-merged-release-pr.outputs.source-sha }} release_key: ${{ needs.verify-merged-release-pr.outputs.release-key }} - release_type: ${{ needs.verify-merged-release-pr.outputs.release-type }} - candidate_sha: ${{ needs.verify-merged-release-pr.outputs.candidate-sha }} - base_source_sha: ${{ needs.verify-merged-release-pr.outputs.base-source-sha }} + release_type: standard secrets: inherit delete-release-branch: @@ -343,6 +207,10 @@ jobs: RELEASE_BRANCH: ${{ needs.verify-merged-release-pr.outputs.release-branch }} run: | set -euo pipefail + [[ "$RELEASE_BRANCH" == releases/* ]] || { + echo "::error::refusing to delete a non-release branch: ${RELEASE_BRANCH}" + exit 1 + } encoded_branch="$(RELEASE_BRANCH="$RELEASE_BRANCH" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["RELEASE_BRANCH"], safe=""))')" gh api -X DELETE "repos/${REPOSITORY}/git/refs/heads/${encoded_branch}" echo "[release-pr] deleted release_branch=${RELEASE_BRANCH}" From b8e08db1c436793b6f264ad35cb3ea6acb4ad1e7 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 4 Sep 2026 13:41:34 +0900 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20=ED=95=AB=ED=94=BD=EC=8A=A4=20PR=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EB=B0=B0=ED=8F=AC=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/hotfix_pr_merged.yml | 349 +++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 .github/workflows/hotfix_pr_merged.yml diff --git a/.github/workflows/hotfix_pr_merged.yml b/.github/workflows/hotfix_pr_merged.yml new file mode 100644 index 0000000..22c55d4 --- /dev/null +++ b/.github/workflows/hotfix_pr_merged.yml @@ -0,0 +1,349 @@ +name: hotfix PR deployment + +# 핫픽스 릴리즈 전용 게이트. hotfixes/** 네임스페이스는 과거 릴리즈 브랜치의 워크플로 스냅샷이 +# 필터하지 않으므로 표준 게이트(release_pr_merged.yml)와 나란히 실행되지 않는다. +on: + pull_request_target: + types: [closed] + branches: + - 'hotfixes/**' + +permissions: + contents: write + pull-requests: read + actions: read + +jobs: + verify-merged-hotfix-pr: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + release-key: ${{ steps.release.outputs.release-key }} + source-sha: ${{ steps.release.outputs.source-sha }} + candidate-sha: ${{ steps.release.outputs.candidate-sha }} + base-source-sha: ${{ steps.release.outputs.base-source-sha }} + release-branch: ${{ steps.release.outputs.release-branch }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + steps: + - name: checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: verify hotfix PR + id: release + shell: bash + run: | + set -euo pipefail + + [[ "$PR_BASE_REF" =~ ^hotfixes/([0-9]{4}-[0-9]{2}-[0-9]{2})/([1-9][0-9]*)$ ]] || { + echo "::error::base ref is not a hotfix release ref: $PR_BASE_REF" + exit 1 + } + release_date="${BASH_REMATCH[1]}" + sequence="${BASH_REMATCH[2]}" + release_key="${release_date}/${sequence}" + + # >>> common PR metadata verification (keep byte-identical between release_pr_merged.yml and hotfix_pr_merged.yml) + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { + echo "::error::release PR number is invalid: $PR_NUMBER" + exit 1 + } + [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ && "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ && "$PR_MERGE_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::release PR contains an invalid commit SHA" + exit 1 + } + + pr_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + PR_JSON="$pr_json" EXPECTED_NUMBER="$PR_NUMBER" EXPECTED_BASE="$PR_BASE_REF" EXPECTED_HEAD="$PR_HEAD_REF" \ + EXPECTED_HEAD_SHA="$PR_HEAD_SHA" EXPECTED_BASE_SHA="$PR_BASE_SHA" EXPECTED_MERGE_SHA="$PR_MERGE_SHA" \ + REPOSITORY="$REPOSITORY" python3 - <<'PY' + import json + import os + import sys + + pr = json.loads(os.environ['PR_JSON']) + head = pr.get('head') or {} + base = pr.get('base') or {} + errors = [] + if (head.get('repo') or {}).get('full_name') != os.environ['REPOSITORY']: + errors.append('PR head repository is not this repository') + if (base.get('repo') or {}).get('full_name') != os.environ['REPOSITORY']: + errors.append('PR base repository is not this repository') + if pr.get('number') != int(os.environ['EXPECTED_NUMBER']): + errors.append('PR number differs from the event') + if not pr.get('merged'): + errors.append('PR is not merged') + if head.get('ref') != os.environ['EXPECTED_HEAD'] or head.get('sha') != os.environ['EXPECTED_HEAD_SHA']: + errors.append('PR head differs from the event') + if base.get('ref') != os.environ['EXPECTED_BASE'] or base.get('sha') != os.environ['EXPECTED_BASE_SHA']: + errors.append('PR base differs from the event') + if pr.get('merge_commit_sha') != os.environ['EXPECTED_MERGE_SHA']: + errors.append('PR merge SHA differs from the event') + + if errors: + for error in errors: + print(f'::error::{error}', file=sys.stderr) + sys.exit(1) + PY + # <<< common PR metadata verification + + release_values="$(PR_JSON="$pr_json" EXPECTED_KEY="$release_key" python3 - <<'PY' + import json + import os + import re + import sys + + body = (json.loads(os.environ['PR_JSON']).get('body') or '') + + def one(name, pattern): + values = re.findall(rf'', body) + if len(values) != 1: + print(f'::error::hotfix PR body {name} marker is missing or invalid', file=sys.stderr) + sys.exit(1) + return values[0] + + candidate_sha = one('release-source-sha', r'[0-9a-f]{40}') + key = one('release-key', r'[0-9]{4}-[0-9]{2}-[0-9]{2}/[1-9][0-9]*') + service = one('release-service', r'dashboard') + release_type = one('release-type', r'hotfix') + base_key = one('release-base-key', r'[0-9]{4}-[0-9]{2}-[0-9]{2}/[1-9][0-9]*') + base_source_sha = one('release-base-source-sha', r'[0-9a-f]{40}') + + if key != os.environ['EXPECTED_KEY'] or service != 'dashboard' or release_type != 'hotfix': + print('::error::hotfix PR body markers do not match the release target', file=sys.stderr) + sys.exit(1) + if base_key == key: + print('::error::hotfix base release key must differ from the hotfix release key', file=sys.stderr) + sys.exit(1) + + print(candidate_sha) + print(base_key) + print(base_source_sha) + PY + )" + mapfile -t release_fields <<< "$release_values" + candidate_sha="${release_fields[0]}" + base_key="${release_fields[1]}" + base_source_sha="${release_fields[2]}" + + PR_JSON="$pr_json" python3 - <<'PY' + import json + import os + import sys + + pr = json.loads(os.environ['PR_JSON']) + head = pr.get('head') or {} + errors = [] + if not (head.get('ref') or '').startswith('hotfix/'): + errors.append('hotfix release PR head must match hotfix/**') + if not isinstance(pr.get('changed_files'), int) or pr['changed_files'] < 1: + errors.append('hotfix release PR must contain reviewed file changes') + + if errors: + for error in errors: + print(f'::error::{error}', file=sys.stderr) + sys.exit(1) + PY + + [[ "$candidate_sha" == "$PR_HEAD_SHA" ]] || { + echo "::error::release source marker must equal the reviewed PR head SHA" + exit 1 + } + [[ "$base_source_sha" == "$PR_BASE_SHA" ]] || { + echo "::error::hotfix branch does not point to the declared production source" + exit 1 + } + + git fetch --no-tags origin "$PR_BASE_SHA" "$PR_HEAD_SHA" "$PR_MERGE_SHA" + merge_tree="$(git show -s --format=%T "$PR_MERGE_SHA")" + + # 직전 운영 배포는 표준(release_pr_merged.yml) 또는 핫픽스(이 파일) run 중 가장 최근 성공 run 이다. + release_runs_file="${RUNNER_TEMP}/dashboard-successful-release-runs.json" + hotfix_runs_file="${RUNNER_TEMP}/dashboard-successful-hotfix-runs.json" + latest_run_file="${RUNNER_TEMP}/dashboard-latest-successful-release-run.json" + jobs_file="${RUNNER_TEMP}/dashboard-release-run-jobs.json" + closed_prs_file="${RUNNER_TEMP}/dashboard-closed-release-prs.json" + gh api --paginate --slurp -X GET \ + "repos/${REPOSITORY}/actions/workflows/release_pr_merged.yml/runs" \ + -f status=success -f per_page=100 > "$release_runs_file" + gh api --paginate --slurp -X GET \ + "repos/${REPOSITORY}/actions/workflows/hotfix_pr_merged.yml/runs" \ + -f status=success -f per_page=100 > "$hotfix_runs_file" + selected_run_id="" + while read -r candidate_run_id candidate_verify_job; do + gh api --paginate --slurp -X GET \ + "repos/${REPOSITORY}/actions/runs/${candidate_run_id}/jobs" \ + -f per_page=100 > "$jobs_file" + if JOBS_FILE="$jobs_file" VERIFY_JOB="$candidate_verify_job" python3 - <<'PY' + import json + import os + import sys + + with open(os.environ['JOBS_FILE'], encoding='utf-8') as file: + pages = json.load(file) + jobs = [job for page in pages for job in (page.get('jobs') or [])] + conclusions = {job.get('name'): job.get('conclusion') for job in jobs} + required = { + os.environ['VERIFY_JOB'], + 'deploy-release-applications / gate dashboard release', + 'deploy-release-applications / build and push dashboard image', + 'deploy-release-applications / hand off dashboard image to image updater', + 'delete-release-branch', + } + sys.exit(0 if all(conclusions.get(name) == 'success' for name in required) else 1) + PY + then + selected_run_id="$candidate_run_id" + break + fi + done < <(RELEASE_RUNS_FILE="$release_runs_file" HOTFIX_RUNS_FILE="$hotfix_runs_file" python3 - <<'PY' + import json + import os + + verify_jobs = { + '.github/workflows/release_pr_merged.yml': 'verify-merged-release-pr', + '.github/workflows/hotfix_pr_merged.yml': 'verify-merged-hotfix-pr', + } + runs = [] + for runs_file in (os.environ['RELEASE_RUNS_FILE'], os.environ['HOTFIX_RUNS_FILE']): + with open(runs_file, encoding='utf-8') as file: + pages = json.load(file) + for page in pages: + for run in page.get('workflow_runs') or []: + if run.get('event') in {'pull_request', 'pull_request_target'} and run.get('path') in verify_jobs: + runs.append(run) + runs.sort(key=lambda run: run['created_at'], reverse=True) + for run in runs: + print(run['id'], verify_jobs[run['path']]) + PY + ) + [[ -n "$selected_run_id" ]] || { + echo "::error::no successful dashboard release deployment run was found" + exit 1 + } + gh api -X GET "repos/${REPOSITORY}/actions/runs/${selected_run_id}" > "$latest_run_file" + gh api --paginate --slurp -X GET "repos/${REPOSITORY}/pulls" \ + -f state=closed -f per_page=100 > "$closed_prs_file" + LATEST_RUN_FILE="$latest_run_file" CLOSED_PRS_FILE="$closed_prs_file" \ + EXPECTED_KEY="$base_key" EXPECTED_SOURCE="$base_source_sha" python3 - <<'PY' + import datetime as dt + import json + import os + import re + import sys + + with open(os.environ['LATEST_RUN_FILE'], encoding='utf-8') as file: + run = json.load(file) + with open(os.environ['CLOSED_PRS_FILE'], encoding='utf-8') as file: + pages = json.load(file) + pulls = [pr for page in pages for pr in page] + run_time = dt.datetime.fromisoformat(run['created_at'].replace('Z', '+00:00')) + candidates = [] + for pr in pulls: + merged_at = pr.get('merged_at') + base_ref = ((pr.get('base') or {}).get('ref') or '') + head_sha = ((pr.get('head') or {}).get('sha') or '') + if not merged_at or not base_ref.startswith(('releases/', 'hotfixes/')) or head_sha != run.get('head_sha'): + continue + merged_time = dt.datetime.fromisoformat(merged_at.replace('Z', '+00:00')) + age = (run_time - merged_time).total_seconds() + if 0 <= age <= 900: + candidates.append((merged_time, pr)) + candidates.sort(key=lambda item: item[0], reverse=True) + if not candidates: + print('::error::latest successful release run cannot be bound to its merged release PR', file=sys.stderr) + sys.exit(1) + + pr = candidates[0][1] + body = pr.get('body') or '' + sources = re.findall(r'', body) + keys = re.findall(r'', body) + services = re.findall(r'', body) + hotfix_types = re.findall(r'', body) + if len(sources) != 1 or len(keys) != 1 or services != ['dashboard']: + print('::error::latest successful release PR markers are invalid', file=sys.stderr) + sys.exit(1) + deployed_source = pr.get('merge_commit_sha') if hotfix_types == ['hotfix'] else sources[0] + if keys[0] != os.environ['EXPECTED_KEY'] or deployed_source != os.environ['EXPECTED_SOURCE']: + print('::error::hotfix base is not the latest successful release deployment source', file=sys.stderr) + sys.exit(1) + PY + + successful_candidate_ci="$(gh api "repos/${REPOSITORY}/commits/${PR_HEAD_SHA}/check-runs" \ + --jq '[.check_runs[] | select(.name == "ci" and .status == "completed" and .conclusion == "success")] | length')" + [[ "$successful_candidate_ci" -ge 1 ]] || { + echo "::error::hotfix candidate has no successful ci check" + exit 1 + } + + read -r merge_sha first_parent second_parent extra_parent <<< "$(git rev-list --parents -n 1 "$PR_MERGE_SHA")" + [[ "$merge_sha" == "$PR_MERGE_SHA" && "$first_parent" == "$PR_BASE_SHA" && \ + "$second_parent" == "$PR_HEAD_SHA" && -z "${extra_parent:-}" ]] || { + echo "::error::hotfix release must use a two-parent merge commit with base and reviewed head" + exit 1 + } + expected_merge_tree="$(git merge-tree --write-tree "$PR_BASE_SHA" "$PR_HEAD_SHA")" + [[ "$merge_tree" == "$expected_merge_tree" ]] || { + echo "::error::hotfix merge tree differs from the deterministic base-plus-candidate merge" + exit 1 + } + source_sha="$PR_MERGE_SHA" + + { + echo "release-key=${release_key}" + echo "source-sha=${source_sha}" + echo "candidate-sha=${candidate_sha}" + echo "base-source-sha=${base_source_sha}" + echo "release-branch=${PR_BASE_REF}" + } >> "$GITHUB_OUTPUT" + + { + echo "## Admin Dashboard hotfix PR verified" + echo "- Release key: \`${release_key}\`" + echo "- Release type: \`hotfix\`" + echo "- Candidate SHA: \`${candidate_sha}\`" + echo "- Deployment SHA: \`${source_sha}\`" + echo "- Base release: \`${base_key}\` at \`${base_source_sha}\`" + } >> "$GITHUB_STEP_SUMMARY" + + deploy-release-applications: + needs: verify-merged-hotfix-pr + uses: ./.github/workflows/deploy_release_applications.yml + with: + source_sha: ${{ needs.verify-merged-hotfix-pr.outputs.source-sha }} + release_key: ${{ needs.verify-merged-hotfix-pr.outputs.release-key }} + release_type: hotfix + candidate_sha: ${{ needs.verify-merged-hotfix-pr.outputs.candidate-sha }} + base_source_sha: ${{ needs.verify-merged-hotfix-pr.outputs.base-source-sha }} + secrets: inherit + + delete-release-branch: + needs: + - verify-merged-hotfix-pr + - deploy-release-applications + runs-on: ubuntu-latest + steps: + - name: delete one-time hotfix release branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + RELEASE_BRANCH: ${{ needs.verify-merged-hotfix-pr.outputs.release-branch }} + run: | + set -euo pipefail + [[ "$RELEASE_BRANCH" == hotfixes/* ]] || { + echo "::error::refusing to delete a non-hotfix branch: ${RELEASE_BRANCH}" + exit 1 + } + encoded_branch="$(RELEASE_BRANCH="$RELEASE_BRANCH" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["RELEASE_BRANCH"], safe=""))')" + gh api -X DELETE "repos/${REPOSITORY}/git/refs/heads/${encoded_branch}" + echo "[hotfix-pr] deleted release_branch=${RELEASE_BRANCH}" From 01de162872e4aab4d88e2cbc11f1c9d10fe4f9cb Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 4 Sep 2026 13:44:16 +0900 Subject: [PATCH 5/5] =?UTF-8?q?ci:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20PR=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=97=90=20=ED=95=AB=ED=94=BD=EC=8A=A4=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EB=AA=A8=EB=93=9C=EB=A5=BC=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 --- .github/workflows/release_pr_create.yml | 277 ++++++++++++++++++++++-- 1 file changed, 255 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release_pr_create.yml b/.github/workflows/release_pr_create.yml index abd2375..80ab5e5 100644 --- a/.github/workflows/release_pr_create.yml +++ b/.github/workflows/release_pr_create.yml @@ -11,15 +11,27 @@ on: description: Positive release sequence number required: true type: string + release_type: + description: standard creates releases/ from main with a release PR; hotfix creates hotfixes/ at the deployed production source + required: false + default: standard + type: choice + options: + - standard + - hotfix permissions: contents: write pull-requests: write + actions: read concurrency: group: release-pr-${{ inputs.release_date }}-${{ inputs.sequence }} cancel-in-progress: false +env: + IMAGE_NAME: bottlenote-admin-dashboard + jobs: create-release-pr: runs-on: ubuntu-latest @@ -28,6 +40,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_DATE: ${{ inputs.release_date }} SEQUENCE: ${{ inputs.sequence }} + RELEASE_TYPE: ${{ inputs.release_type }} REPOSITORY: ${{ github.repository }} REPOSITORY_OWNER: ${{ github.repository_owner }} DISPATCH_REF: ${{ github.ref }} @@ -40,8 +53,13 @@ jobs: token: ${{ secrets.GIT_ACCESS_TOKEN }} persist-credentials: true - - name: validate inputs and create release PR + - name: validate inputs and reserve release key + id: reserve shell: bash + env: + REGISTRY_ADDRESS: ${{ secrets.REGISTRY_ADDRESS }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | set -euo pipefail @@ -65,9 +83,72 @@ jobs: echo "::error::sequence must be a positive integer" exit 1 } + [[ "$RELEASE_TYPE" == "standard" || "$RELEASE_TYPE" == "hotfix" ]] || { + echo "::error::release_type must be standard or hotfix" + exit 1 + } release_key="${RELEASE_DATE}/${SEQUENCE}" - release_branch="releases/${release_key}" + image_tag="dashboard_${RELEASE_DATE//-/.}.${SEQUENCE}" + standard_branch="releases/${release_key}" + hotfix_branch="hotfixes/${release_key}" + + url_encode() { + python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["VALUE"], safe=""))' + } + + require_missing_remote_ref() { + local ref_name="$1" + local encoded response + encoded="$(VALUE="$ref_name" url_encode)" + if response="$(gh api --include "repos/${REPOSITORY}/git/ref/heads/${encoded}" 2>&1)"; then + echo "::error::remote ref already exists: ${ref_name}" + exit 1 + fi + if [[ "$response" != *" 404 "* ]]; then + echo "::error::unable to confirm remote ref is absent: ${ref_name}" + exit 1 + fi + } + + # 표준과 핫픽스는 같은 릴리즈 키에서 같은 immutable 이미지 태그를 만든다. + # 두 네임스페이스의 브랜치와 레지스트리 태그가 모두 비어 있어야 키를 예약할 수 있다. + require_missing_remote_ref "$standard_branch" + require_missing_remote_ref "$hotfix_branch" + + image="${REGISTRY_ADDRESS}/${IMAGE_NAME}:${image_tag}" + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_ADDRESS" \ + --username "$REGISTRY_USERNAME" --password-stdin + manifest_error_log="${RUNNER_TEMP}/manifest-error.log" + if docker manifest inspect "$image" >/dev/null 2>"$manifest_error_log"; then + echo "::error::image tag already exists for release key ${release_key}: ${image}" + exit 1 + fi + if ! grep -Eqi 'manifest unknown|no such manifest|not found' "$manifest_error_log"; then + cat "$manifest_error_log" >&2 + echo "::error::failed to verify whether image tag exists: ${image}" + exit 1 + fi + + release_branch="$standard_branch" + [[ "$RELEASE_TYPE" != "hotfix" ]] || release_branch="$hotfix_branch" + { + echo "release-key=${release_key}" + echo "image-tag=${image_tag}" + echo "release-branch=${release_branch}" + } >> "$GITHUB_OUTPUT" + + - name: create standard release PR + if: env.RELEASE_TYPE == 'standard' + shell: bash + env: + RELEASE_KEY: ${{ steps.reserve.outputs.release-key }} + RELEASE_BRANCH: ${{ steps.reserve.outputs.release-branch }} + run: | + set -euo pipefail + + release_key="$RELEASE_KEY" + release_branch="$RELEASE_BRANCH" release_branch_pushed=false main_pushed=false created_pr_url="" @@ -105,26 +186,6 @@ jobs: trap 'handle_partial_failure "$?"' ERR trap cleanup_body_file EXIT - url_encode() { - python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["VALUE"], safe=""))' - } - - require_missing_remote_ref() { - local ref_name="$1" - local encoded response - encoded="$(VALUE="$ref_name" url_encode)" - if response="$(gh api --include "repos/${REPOSITORY}/git/ref/heads/${encoded}" 2>&1)"; then - echo "::error::remote ref already exists: ${ref_name}" - exit 1 - fi - if [[ "$response" != *" 404 "* ]]; then - echo "::error::unable to confirm remote ref is absent: ${ref_name}" - exit 1 - fi - } - - require_missing_remote_ref "$release_branch" - open_pr_count="$(gh api -X GET "repos/${REPOSITORY}/pulls" \ -f state=open \ -f base="$release_branch" \ @@ -229,3 +290,175 @@ jobs: echo "- Head ref: \`main\`" echo "- Post-create validation: \`passed\`" } >> "$GITHUB_STEP_SUMMARY" + + # 핫픽스 릴리즈 브랜치는 직전 운영 배포 소스에서만 잘린다. 핫픽스 PR(hotfix/** -> hotfixes/) 자체는 + # 사람이 만들고, 여기서 출력한 본문 마커를 PR 본문에 그대로 넣는다. + - name: create hotfix release branch + if: env.RELEASE_TYPE == 'hotfix' + shell: bash + env: + RELEASE_KEY: ${{ steps.reserve.outputs.release-key }} + RELEASE_BRANCH: ${{ steps.reserve.outputs.release-branch }} + run: | + set -euo pipefail + + release_key="$RELEASE_KEY" + hotfix_branch="$RELEASE_BRANCH" + + release_runs_file="${RUNNER_TEMP}/dashboard-successful-release-runs.json" + hotfix_runs_file="${RUNNER_TEMP}/dashboard-successful-hotfix-runs.json" + latest_run_file="${RUNNER_TEMP}/dashboard-latest-successful-release-run.json" + jobs_file="${RUNNER_TEMP}/dashboard-release-run-jobs.json" + closed_prs_file="${RUNNER_TEMP}/dashboard-closed-release-prs.json" + gh api --paginate --slurp -X GET \ + "repos/${REPOSITORY}/actions/workflows/release_pr_merged.yml/runs" \ + -f status=success -f per_page=100 > "$release_runs_file" + gh api --paginate --slurp -X GET \ + "repos/${REPOSITORY}/actions/workflows/hotfix_pr_merged.yml/runs" \ + -f status=success -f per_page=100 > "$hotfix_runs_file" + selected_run_id="" + while read -r candidate_run_id candidate_verify_job; do + gh api --paginate --slurp -X GET \ + "repos/${REPOSITORY}/actions/runs/${candidate_run_id}/jobs" \ + -f per_page=100 > "$jobs_file" + if JOBS_FILE="$jobs_file" VERIFY_JOB="$candidate_verify_job" python3 - <<'PY' + import json + import os + import sys + + with open(os.environ['JOBS_FILE'], encoding='utf-8') as file: + pages = json.load(file) + jobs = [job for page in pages for job in (page.get('jobs') or [])] + conclusions = {job.get('name'): job.get('conclusion') for job in jobs} + required = { + os.environ['VERIFY_JOB'], + 'deploy-release-applications / gate dashboard release', + 'deploy-release-applications / build and push dashboard image', + 'deploy-release-applications / hand off dashboard image to image updater', + 'delete-release-branch', + } + sys.exit(0 if all(conclusions.get(name) == 'success' for name in required) else 1) + PY + then + selected_run_id="$candidate_run_id" + break + fi + done < <(RELEASE_RUNS_FILE="$release_runs_file" HOTFIX_RUNS_FILE="$hotfix_runs_file" python3 - <<'PY' + import json + import os + + verify_jobs = { + '.github/workflows/release_pr_merged.yml': 'verify-merged-release-pr', + '.github/workflows/hotfix_pr_merged.yml': 'verify-merged-hotfix-pr', + } + runs = [] + for runs_file in (os.environ['RELEASE_RUNS_FILE'], os.environ['HOTFIX_RUNS_FILE']): + with open(runs_file, encoding='utf-8') as file: + pages = json.load(file) + for page in pages: + for run in page.get('workflow_runs') or []: + if run.get('event') in {'pull_request', 'pull_request_target'} and run.get('path') in verify_jobs: + runs.append(run) + runs.sort(key=lambda run: run['created_at'], reverse=True) + for run in runs: + print(run['id'], verify_jobs[run['path']]) + PY + ) + [[ -n "$selected_run_id" ]] || { + echo "::error::no successful dashboard release deployment run was found" + exit 1 + } + gh api -X GET "repos/${REPOSITORY}/actions/runs/${selected_run_id}" > "$latest_run_file" + gh api --paginate --slurp -X GET "repos/${REPOSITORY}/pulls" \ + -f state=closed -f per_page=100 > "$closed_prs_file" + base_values="$(LATEST_RUN_FILE="$latest_run_file" CLOSED_PRS_FILE="$closed_prs_file" python3 - <<'PY' + import datetime as dt + import json + import os + import re + import sys + + with open(os.environ['LATEST_RUN_FILE'], encoding='utf-8') as file: + run = json.load(file) + with open(os.environ['CLOSED_PRS_FILE'], encoding='utf-8') as file: + pages = json.load(file) + pulls = [pr for page in pages for pr in page] + run_time = dt.datetime.fromisoformat(run['created_at'].replace('Z', '+00:00')) + candidates = [] + for pr in pulls: + merged_at = pr.get('merged_at') + base_ref = ((pr.get('base') or {}).get('ref') or '') + head_sha = ((pr.get('head') or {}).get('sha') or '') + if not merged_at or not base_ref.startswith(('releases/', 'hotfixes/')) or head_sha != run.get('head_sha'): + continue + merged_time = dt.datetime.fromisoformat(merged_at.replace('Z', '+00:00')) + age = (run_time - merged_time).total_seconds() + if 0 <= age <= 900: + candidates.append((merged_time, pr)) + candidates.sort(key=lambda item: item[0], reverse=True) + if not candidates: + print('::error::latest successful release run cannot be bound to its merged release PR', file=sys.stderr) + sys.exit(1) + + pr = candidates[0][1] + body = pr.get('body') or '' + sources = re.findall(r'', body) + keys = re.findall(r'', body) + services = re.findall(r'', body) + hotfix_types = re.findall(r'', body) + if len(sources) != 1 or len(keys) != 1 or services != ['dashboard']: + print('::error::latest successful release PR markers are invalid', file=sys.stderr) + sys.exit(1) + deployed_source = pr.get('merge_commit_sha') if hotfix_types == ['hotfix'] else sources[0] + if not re.fullmatch(r'[0-9a-f]{40}', deployed_source or ''): + print('::error::latest successful release deployment source is not a commit SHA', file=sys.stderr) + sys.exit(1) + print(keys[0]) + print(deployed_source) + print(run['html_url']) + print(pr['html_url']) + PY + )" + mapfile -t base_fields <<< "$base_values" + base_key="${base_fields[0]}" + base_source_sha="${base_fields[1]}" + base_run_url="${base_fields[2]}" + base_pr_url="${base_fields[3]}" + [[ "$base_key" != "$release_key" ]] || { + echo "::error::hotfix release key must differ from the deployed base release key: ${base_key}" + exit 1 + } + + git fetch --no-tags origin "$base_source_sha" + git cat-file -e "${base_source_sha}^{commit}" + # 정보성 경고(대안 A 생성 가드): 운영 소스 스냅샷에 pull_request 트리거가 남아 있어도 + # hotfixes/** 네임스페이스는 그 스냅샷의 releases/** 필터에 걸리지 않는다. + if git show "${base_source_sha}:.github/workflows/release_pr_merged.yml" 2>/dev/null | grep -Eq '^ pull_request:'; then + echo "::warning::production source ${base_source_sha} still carries an on.pull_request release workflow snapshot; hotfixes/** is filtered out of it" + fi + + git push origin "${base_source_sha}:refs/heads/${hotfix_branch}" + git fetch --no-tags origin "+refs/heads/${hotfix_branch}:refs/remotes/origin/${hotfix_branch}" + remote_hotfix_sha="$(git rev-parse "origin/${hotfix_branch}")" + [[ "$remote_hotfix_sha" == "$base_source_sha" ]] || { + echo "::error::hotfix branch ${hotfix_branch} does not point to the deployed production source ${base_source_sha}. Manual cleanup is required." + exit 1 + } + + { + echo "## Admin Dashboard hotfix release branch created" + echo "- Release key: \`${release_key}\`" + echo "- Hotfix base ref: \`${hotfix_branch}\` at \`${base_source_sha}\`" + echo "- Base release: \`${base_key}\` (${base_pr_url}, ${base_run_url})" + echo "- Next: open a PR from \`hotfix/\` to \`${hotfix_branch}\` and paste the markers below into the PR body" + echo "- Replace \`\` with the final reviewed PR head SHA before merging" + echo + echo '```' + echo "" + echo "" + echo "" + echo "" + echo "" + echo "" + echo '```' + } >> "$GITHUB_STEP_SUMMARY"