📕 목차
1. 설계
2. 태그 생성 및 릴리즈 자동화
3. 배포 workflows 호출
1. 설계
📌 고려 사항
멀티 모듈 아키텍처로 구성된 프로젝트를 진행하던 중, 드디어 Application 영역의 도메인이 하나 더 추가가 되었다.
기존의 CD 파이프라인은 external-api 모듈만을 타겟팅하여 빌드하고 있었는데, 이 방식은 현재 작업한 내용이 Socket, Admin, Batch 모듈과 관련한 작업이라면 문제가 발생할 수밖에 없었다.
그래서 CD 파이프라인을 분리하기 위해 git tag를 활용하는 것이 가장 안전하다고 판단했다.
하지만 태그는 혼자서 하는 프로젝트에서 조차 휴먼 에러가 쉽게 발생할 수 있는 것 중 하나인 것을 이미 경험해봐서 알고 있었다.
또한 작업 후에 태그를 push하고 릴리즈를 반영하는 등의 불필요한 인적 리소스 소모를 모두 자동화하여 처리하고자 하는 욕심이 있었다.
즉, 내가 자동화하고 싶은 부분은 다음과 같았다.
- PR을 작성하고 병합하면 자동으로 태그가 생성되어야 한다.
- 이 때, 모듈명을 기반으로 접두사가 결정되어야 한다. (ex. Api-v1.0.0)
- 태그가 생성되면 자동으로 릴리즈가 되어야 한다.
- 태그와 릴리즈가 성공하면 배포 파이프라인을 통해 서버에 반영되어야 한다.
📌 플로우 다이어그램
결국 설계대로 완성하긴 했지만, 중간중간 우여곡절을 제법 많이 거쳤다.
특히 태그를 자동으로 생성하는 라이브러리의 설명이 너무 불친절해서 코드까지 전부 살펴보고 겨우 원하는 대로 얼추 구현해내는 데 성공했다.
2. 태그 생성 및 자동화
📌 PR 제목
태그의 접두사, 즉 배포하려는 애플리케이션에 해당하는 모듈을 결정하는 방법이 무엇이 있을까 고민을 했는데, PR 제목의 prefix로 작성하는 것이 가장 좋다고 생각했다.
왜냐하면, commit 정보를 기반으로 하기엔 기존 컨벤션까지 건드려야 할 판이었기 때문인데다, PR 제목에 명시를 해두면 팀원이 실수하고 명시하지 않더라도 리뷰어가 확인하기 너무 편한 위치기 때문이었다.
(지금 생각해보면 Label을 사용하는 것도 좋은 방법이었지 않을까 싶다.)
# PR 제목으로 부터 모듈명 추출 (ex. Api, Batch, Admin, Socket)
- name: extract PR info
id: module_prefix
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
echo "PR title : $PR_TITLE"
if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket): ]]; then
PREFIX="${BASH_REMATCH[1]}"
echo "Prefix: $PREFIX"
echo "module=$PREFIX" >> $GITHUB_OUTPUT
else
echo "PR title does not match the pattern"
exit 1
fi
내 프로젝트에서는 Api, Batch, Admin, Socket에 대한 릴리즈가 발생할 것이라 예상하고 있기에, 정규 표현식에서도 해당 문자만 체크하도록 만들었다.
이건 github actions와 상관없는 shell 명령어이므로, 정말 잘 동작하는지 궁금하면 나처럼 직접 리눅스 터미널에다가 입력해보면 된다.
모든 PR 제목의 접두사에 빌드시킬 `{빌드할 모듈명}: `을 붙여주면 끝난다.
📌 태그 생성
name: Bump version
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bump version and push tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create a GitHub release
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.tag_version.outputs.new_tag }}
name: Release ${{ steps.tag_version.outputs.new_tag }}
body: ${{ steps.tag_version.outputs.changelog }}
위 스크립트는 README에서 가져온 예시 스크립트에 해당한다.
해당 라이브러리를 사용하면 이전 버전을 기반으로 새로운 버전을 추론하여 tag를 등록해준다.
말만 들으면 참 편리하지만, 멋 모르고 사용했다간 나처럼 봉변당할 수 있다.
- major, minor, patch 업데이트 판단 여부는 커밋 이력의 접두사를 기반으로 한다.
- commit은 Angular Commit Message Conventions를 따른다.
- 주의할 점은 커밋 이력 중 가장 높은 우선 순위를 갖는 것을 기준으로 업데이트된다.
(예를 들어, feat과 fix가 모두 존재하면 feat이 더 높으므로 minor 업데이트라고 판단한다.) - 그리고 이건 아직도 이유를 모르겠는데, 난 무슨 짓을해도 major 업데이트가 안 됐다...^^ 그래서 release rule 옵션을 지정해주었다.
- 따로 설정하지 않으면 main, master 브랜치 외엔 pre-release로 간주하여 최종 생성된 태그 접미사로 브랜치명이 붙는다.
- 뭐, 사실 이게 일반적이긴 하겠지만 우리랑은 안 맞아서 설정값을 수정했다.
jobs:
extract-info:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write
steps:
# ... 앞은 생략
- name: version and tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: patch
release_branches: main,dev.*
custom_release_rules: release:major, feat:minor:Features, refactor:minor:Refactoring, fix:patch:Bug Fixes, hotfix:patch:Hotfixes, docs:patch:Documentation, style:patch:Styles, perf:patch:Performance Improvements, test:patch:Tests, ci:patch:Continuous Integration, chore:patch:Chores, revert:patch:Reverts
tag_prefix: '${{ steps.module_prefix.outputs.module }}-v'
옵션을 조금 설명해둘 건데, github_token 외엔 전부 옵션값이라 프로젝트 입맛에 맞추어서 설정하면 된다.
- github_token: `secrets.GITHUB_TOKEN`을 가져와야 한다. 기본으로 설정되어 있는 값이므로 딱히 할 건 없지만, permissions에서 권한을 조정해줄 필요가 있다.
- default_bump: 커밋 내역에 다음 버전을 참고할 만한 접두사가 존재하지 않으면 기본으로 patch 업데이트로 간주한다. 기본값이라 안 써줘도 되는데, 팀원들에게 인지시키려고 명시적으로 표현함.
- release_branches: 만약 main, master 브랜치를 제외한 곳에서도 pre-release가 아닌, release가 되게 하려면 `dev.*`, `release.*` 따위로 추가해주면 된다.
- custom_release_rules: <keyword>:<release_type>:<changelog_section> 포맷을 맞춰서 작성하면 된다. 난 기본값 쓰기 싫었는데, release 접두사 하나 때문에 전부 썼다. (changelog_section에 대한 용도는 바로 밑에 추가)
- tag_prefix: 기본값은 `v`지만, 나는 모듈명을 앞에 붙여서 `Api-v` 따위로 생성되도록 만들었다.
🟡 change log
커밋 접두사를 기반으로 변경 이력을 정리해준다.
이게 왜 있나? 싶겠지만, 해당 데이터를 사용하 Release 내용을 개발자가 따로 작성해줄 필요가 없어진다.
📌 Relase 자동화
- name: Create a GitHub release
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.tag_version.outputs.new_tag }}
name: ${{ steps.tag_version.outputs.new_tag }}
body: ${{ steps.tag_version.outputs.changelog }}
이건 진짜 변경할 게 딱히 없다.
위에서 만들어진 데이터를 그대로 넣어주기만 하면 알아서 릴리즈가 된다.
📌 태그 & 릴리즈 자동화 중간 파이프라인
name: Extract Module and Version from PR Title
on:
pull_request:
types: [closed]
jobs:
extract-info:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write
steps:
- name: Checkout PR
uses: actions/checkout@v4
- name: extract PR info
id: module_prefix
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
echo "PR title : $PR_TITLE"
if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket): ]]; then
PREFIX="${BASH_REMATCH[1]}"
echo "Prefix: $PREFIX"
echo "module=$PREFIX" >> $GITHUB_OUTPUT
else
echo "PR title does not match the pattern"
exit 1
fi
- name: version and tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: patch
release_branches: main,dev.*
custom_release_rules: release:major, feat:minor:Features, refactor:minor:Refactoring, fix:patch:Bug Fixes, hotfix:patch:Hotfixes, docs:patch:Documentation, style:patch:Styles, perf:patch:Performance Improvements, test:patch:Tests, ci:patch:Continuous Integration, chore:patch:Chores, revert:patch:Reverts
tag_prefix: '${{ steps.module_prefix.outputs.module }}-v'
- name: Create a GitHub release
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.tag_version.outputs.new_tag }}
name: ${{ steps.tag_version.outputs.new_tag }}
body: ${{ steps.tag_version.outputs.changelog }}
중간 파이프라인이 아닌 이유는 아직 빌드할 파이프라인으로 연결하는 작업을 해주지 않았기 때문.
우선 여기까지만 해도 태그 생성과 릴리즈까지는 문제없이 수행할 수 있다.
3. 배포 workflows 호출
📌 시작에 앞서..
여기서 가장 고생했던 부분은 처음 가정이 완전히 틀렸던 것에 있었다.
태그 & 릴리즈 자동화 파이프라인에선 이벤트가 무려 두 가지나 발생한다.
첫 번째는 태그가 생성되는 것이고, 두 번째는 릴리즈가 발생한다는 것.
그래서 다음과 같이 event trigger를 설정하면 당연히 workflows가 작동할 것이라 믿었다.
name: Continuous Delpoyment - Api
on:
push:
tags:
- 'Api-v*.*.*'
## 혹은
on:
release:
type: [published]
하지만 위 두 가지 방법은 물론이고, 그 어떤 트리거를 걸어도 이전 workflows의 이벤트를 감지하지 못 했다.
내 추측이건데 workflows가 감지하는 trigger는 외부에서 발생한 이벤트여야지, 어떤 actions에 의해 발생한 이벤트가 아니어야 한다.
즉, 태그 & 릴리즈 자동화 이후 실행할 workflows를 결정하는 것은 event 방식이 아니라 직접 workflows를 호출해주어야만 한다.
📌 4가지 선택권
다음에 실행할 workflows 혹은 actions를 선택하는 4가지 방법이 존재한다.
정확히는 reuse pipeline을 사용하기 위한 것이지만 도구의 사용법은 내가 만들면 그만.
- workflow_run: 태그 생성 CD가 종료되는 이벤트를 감지하여 배포 CD를 실행할 수 있다. → 문제는 모든 배포 관련 workflows가 실행될테므로 보류
- workflow_call: 태그 생성 CD가 다음으로 진행할 workflows를 선택한다. → step 단위가 아닌 job의 run 단위에서 실행해야 한다는 단점이 있다.
- workflow_dispatch: 다른 repository의 workflows를 호출하기 위한 것인데, 이렇게까지 할 필요는 없으므로 기각
- composite actions: step 단위에서 사용할 수 있어 좋긴 하나, 굳이...?
run과 call의 차이는 주체가 어디냐로 갈린다.
run은 누군가 실행 완료되면 내가 반드시 실행되어야 하는 것이고, call은 누군가 나를 호출하면 실행되는 방식.
나는 여러 개의 배포 파이프라인을 가지고 있으므로 workflow_call을 사용하는 것이 가장 적절하다고 여겼다.
📌 workflow_call
name: Continuous Deployment - External API
on:
workflow_call:
inputs:
tags:
description: '배포할 Api 모듈 태그 정보 (Api-v*.*.*)'
required: true
type: string
name: Continuous Deployment - Batch
on:
workflow_call:
inputs:
tags:
description: '배포할 Batch 모듈 태그 정보 (Batch-v*.*.*)'
required: true
type: string
각각의 파이프라인에서 workflow_call 이벤트 트리거를 정의한다.
여기서 inputs는 매개변수 쯤으로 생각하면 된다.
# 버전 정보 추출 (태그 정보에서 *.*.*만 추출)
- name: Get Version
id: get_version
run: |
RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${{ inputs.tags }})"
echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT
inputs로 받은 값은 ${{}} 블럭 내에서 사용하면 된다.
위는 사용 예시
📌 호출자 수정
다시 태그 & 릴리즈 생성 파이프라인으로 돌아와서, workflow_call을 호출하는 것은 step에서는 불가능하므로 별도의 run에서 호출해야 한다.
그렇다면 extract-info 런이 제일 처음 실행하고, 해당 런의 output 정보를 기반으로 다음 작업을 결정할 수 있을 것이다.
jobs:
extract-info:
outputs:
module: ${{ steps.module_prefix.outputs.module }}
tag: ${{ steps.tag_version.outputs.new_tag }}
기존의 run에서 outputs를 정의해준다.
이렇게 되면 다음 run에서 output 정보를 사용할 수 있다.
jobs:
extract-info:
outputs:
module: ${{ steps.module_prefix.outputs.module }}
tag: ${{ steps.tag_version.outputs.new_tag }}
...
call-external-api-deploy:
needs: extract-info
if: ${{ needs.extract-info.outputs.module == 'Api' }}
uses: ./.github/workflows/deploy-external-api.yml
secrets: inherit
with:
tags: ${{ needs.extract-info.outputs.tag }}
call-batch-deploy:
needs: extract-info
if: ${{ needs.extract-info.outputs.module == 'Batch' }}
uses: ./.github/workflows/deploy-batch.yml
secrets: inherit
with:
tags: ${{ needs.extract-info.outputs.tag }}
- needs: workflows 호출을 위한 run은 tag 생성을 위한 run보다 먼저 수행되어선 안 된다. 따라서 extract-info run이 끝날 때까지 대기하도록 한다. (그리고 outputs 값을 빼온다.)
- if: 각각의 deploy run들은 PR 제목의 접두사에서 따온 모듈명의 정보로 실행될 지 여부를 판단한다.
- secrets: 이거 때문에 마지막에 뒷통수 맞았는데, 피호출자 측에선 github에 등록한 secret key에 함부로 접근하지 못 한다. 따라서 secrets 설정을 현재 workflows에서 상속받는다는 의미로 inherit을 사용해야만 한다. (그게 싫으면 직접 파라미터 하나씩 다 넘겨주면 됨)
- with.tags: 내가 정의했던 배포 workflows 호출을 위한 매개변수
각 배포 파이프라인을 호출하는 run에서도 pr 병합 여부를 확인하려고 했는데, extract-info run이 조건문에 걸려 종료되면 이후 run도 실행되지 않기에 그냥 내버려뒀다.
진짜 없는 시간 겨우 쥐어짜내서 간만에 포스팅하긴 했는데, 너무 피곤해서 성의없게 써버렸다..ㅠ
여튼 이렇게 하니 내가 원했던 파이프라인 구축에 성공~