Git action에서 python을 활용하여 README 자동 업데이트 하기
일전에 github action을 이용하여 TIL의 README를 자동으로 업데이트하는 법을 다뤄봤다.
Git action을 이용한 TIL README 작성
최근 github을 관리하고 싶어 개발자들이 많이 하는 TIL(Today I Learned)을 작성해보고 있다. 지금은 영어 공부 때문에 개발 공부를 많이 하고 있진 않지만 github에 TIL을 작성하기에 TIL repository에 README
lys7aves.tistory.com
그러나 항상 최상위 폴더와 그 바로 아래 있는 파일만 인식하고, 하위 폴더들과 그 안에 있는 파일들은 인식하지 못하여 내내 아쉬움이 남았었다. 그러다 다시 한번 README파일을 자동으로 업데이트하면 좋을 것 같다는 생각이 들었고, 결국 python을 이용하여 이 문제를 해결하는 방법에 대해 알아내었다.
Github action에서 python 실행하기
우선 이전에 포스팅했던대로 .github/workflows/ 디렉토리에 yml 파일을 만든 후 아래와 같이 작성해 준다.
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python application
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f ./.github/config/requirements.txt ]; then pip install -r ./.github/config/requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Exectue python
run: |
python ./.github/src/update-readme.py
- name: Set up Git user name and email
run: |
git config --global user.name 'lys7aves'
git config --global user.email 'lys7aves@gmail.com'
- name: Commit and push changes
run: |
git add .
git commit -m "Update files"
git push
action은 하나의 job에 여러 step이 들어가는 구조이다. 내가 하고자 하는 것들을 step에 하나하나 추가한다고 생각하면 쉽다. 그렇다면 우리는 무엇을 해야 될까? 크게 아래와 같이 4가지 step이 필요할 것이다.
- 기본 설정 (checkout이나 필요한 python 모듈 설치 등)
- 미리 작성해 둔 python 파일 실행
- git 설정
- git commit
1번 step이 14번째 줄부터 30번째 줄에 해당하는 부분이다. action/checkout을 해주고(왜 하는지 모르지만 항상 한다...), python을 설치해 준다. 이후 혹시라도 필요한 python 모듈이 있으면 설치해준다. 이는 필요시 .github/config/requirements.txt에 미리 작성해 두면 된다. (사실 이번 포스팅에서는 없어도 되는 step이다.) 이후 Lint with flake8을 해주는데 이건 뭔지 잘 모른다;;
action을 잘 다룰 줄 모르기 때문에 비교적 익숙한 python에서 README를 자동으로 작성하면 편할 것이다. 따라서 32번째 줄에 해당하는 스텝이 미리 짜둔 python파일을 실행시키는 부분이다. 나는 .github/src/ 디렉토리에 python 파일을 만들어두었다.
이후 36번째 줄과 41번째 줄에 해당하는 스텝이 앞서 말한 3, 4번 스텝에 해당한다.
권한 부여
action에서 git commit을 하기 위해서는 권한이 필요하다. (처음에 이거 때문에 여러 방법들을 포기했었는데, 이걸 미리 알았더라면 그중 몇 개는 성공했을 지도....) 아마 workflow에 권한이 없으면 commit 부분에서 오류 메시지를 받을 것이다.
workflow 권한 부여하기:
- 현재 작업 중인 repository의 Settings로 이동
- Actions - General 탭으로 이동
- Workflow permissions에서 Read and write permissions 선택
그 아래에 있는 "Allow GitHub Actions to create and approve pull requests"가 필요한지는 모르겠지만 나는 일단 체크해 뒀다.
README를 작성해 주는 python파일 만들기
.github/src/ 디렉토리를 만들어 python 파일을 하나 만들고 아래와 같이 코드를 작성해 준다. (python파일을 어디 만들던 상관은 없다.)
import os
from datetime import datetime
import re
# 탐색할 root 경로
dir_path = "."
# ignore 파일을 읽어서 패턴 목록을 리스트로 저장
with open('./.github/config/.ignore', 'r') as f:
ignore = f.readlines()
patterns = []
for p in ignore:
pattern = r""
for c in p.strip():
if c == '*':
pattern = pattern + r".*"
elif c in ".^$*+?{}[]|()": # 메타 문자
pattern = pattern + r"[{}]".format(c)
else:
pattern = pattern + r'{}'.format(c)
patterns.append(pattern)
# ignore 패턴과 일치하는지 확인하는 함수
def check_ignore_pattern(item_path):
for pattern in patterns:
if re.fullmatch(pattern, item_path[(len(dir_path)+1):]): # 항상 붙는 "[dir_path]/" 제거
return True
return False
def find_target(path, level):
file_list = []
# 하위 디렉토리 순환
for item in os.listdir(path):
item_path = os.path.join(path, item)
if check_ignore_pattern(item) or check_ignore_pattern(item_path): # 파일 이름이나 경로가 ignore 조건을 만족하면 무시
continue
# 파일(혹은 디렉토리) 리스트에 추가하기
mtime = datetime.fromtimestamp(os.stat(item_path).st_mtime) # 수정 날짜 가져오기
mtime = mtime.strftime('%a %b %d %Y') # 날짜 형식 변환
file_list.append([item, item_path, mtime, []])
# 디렉토리면 하위 디렉토리 탐색
if os.path.isdir(item_path):
sub_list = find_target(item_path, level+1)
if len(sub_list) == 0: # 만약 하위 디렉토리에 아무것도 없으면 현재 디렉토리도 삭제
file_list.pop()
elif len(sub_list) == 1: # 만약 하위 디렉토리에 파일이 하나라면 합치기
sub_file = sub_list[0]
sub_file[0] = item + '/' + sub_file[0]
file_list[-1] = sub_file
else:
file_list[-1][3] = sub_list
else:
only_files.append([item, item_path, mtime])
return file_list
# 재귀적으로 파일 출력
def print_file_list(f, file_list, level):
file_list.sort(key=lambda file: file[2], reverse=True)
for file in file_list:
for i in range(level):
f.write(" ")
if len(file[3]) == 0: # 파일이면 수정 날짜와 함께 출력
f.write("- [{}](\"{}\") - {}\n".format(file[0], file[1].replace(' ', '_'), file[2]))
else: # 디렉토리면 날짜 빼고 출력
f.write("- [{}](\"{}\")\n".format(file[0], file[1].replace(' ', '_')))
print_file_list(f, file[3], level+1)
only_files = []
file_list = find_target(dir_path, 0)
only_files.sort(key=lambda file: file[2], reverse=True)
# README.md 파일을 열어 파일 경로를 추가
with open("README.md", "w") as f:
f.write("# mathematics\n")
f.write("A collection of notes and solutions on various mathematical topics.\n\n")
f.write("---\n\n")
most = 3
f.write("### {} most recent study\n".format(most))
for i in range(most):
f.write("- [{}](\"{}\") - {}\n".format(only_files[i][0], only_files[i][1].replace(' ', '_'), only_files[i][2]))
f.write("\n")
file_list.sort(key=lambda file: file[2], reverse=True)
f.write("### Categories\n")
for file in file_list:
f.write("- [{}](#{})\n".format(file[0], file[0]))
f.write("\n")
for file in file_list:
f.write("### [{}](#{})\n".format(file[0], file[0]))
print_file_list(f, file[3], 0)
f.write("\n")
처음에는 간단하게 작성하였지만 만들다 보니 욕심이 생겨 이것저것 넣다 보니 코드가 좀 길어졌다.
find_target 함수가 핵심이다. python에서 os.listdir 함수를 이용하면 특정 디렉토리에 있는 파일들의 목록을 살펴볼 수 있다. 이를 이용하여 재귀적으로 github에 있는 모든 파일들을 탐색해 준다. 그러나 이렇게 하면 문제가 발생하는데, 보이지는 않지만 .git 디렉토리도 탐색을 하게 되고, 내가 원치 않은 파일들도 다 list에 추가가 된다. 따라서 미리 .ignore 파일을 만들어 놓고, 여기에 적힌 규칙에 해당하는 파일들을 무시하도록 check_ignore_pattern 함수를 작성하였다.
그 이외에도 아래와 같은 사소한 기능들을 구현해 두었다.
- 디렉토리 안에 하나의 디렉토리 혹은 하나의 파일만 있으면 이를 합쳐서 표현
- 파일 목록을 따로 저장하여 최근에 업데이트된 파일들을 출력
- 카테고리 및 파일 목록 출력 시 최근 업데이트된 파일(이 들어 있는 디렉토리)이 먼저 출력되도록 구현 (이 때문에 하나의 파일 혹은 디렉토리를 [item, item_path, mtime, []] 이런 식으로 표현함. 4번째 인자에 하위 파일들이 들어가게 됨.)
결과
다음은 github action으로 자동으로 만들어진 README.md 파일이다.
Linear-Algebra-and-Its-Applications 카테고리를 보면 Chapter 1 Linear Equations in Linear Algebra 디렉토리 안에 하나의 디렉토리와 그 안에 하나의 파일만 있어 이름이 합쳐져 있는 것을 볼 수 있다.
마치며...
처음에는 조금만 찾아보면 금방 할 줄 알고 github action이 뭔지도 모른 채 삽질을 시작했다. 작동 원리도 잘 모르다 보니 막히는 부분이 많았고, 새로 시작하는 마음으로 action이 뭔지 어떻게 돌아가는지에 대해 간단히 알아보았다. 그 과정에서 내 입맛대로 하기 위해선 python을 이용하는 게 쉬울 것 같다는 생각이 들었고, 결국 원하는 작업을 할 수 있게 되었다. 이 과정에서, 역시 조금 오래 걸릴 수는 있었도 무언가를 할 때는 기본 원리를 파악하는 게 중요하다는 것을 다시 한번 느끼게 되었다.