Profile picture

[Github Actions] 깃허브 커밋 자동화

JaehyoJJAng2023년 04월 20일

🌏 개요

현재 Ncloud 서버를 무료 크레딧으로 임대하고 있고

파이썬, 쉘 스크립트를 기반으로 1일 1커밋 자동화 코드를 작성하여

서버에서 매일 23:30분에 해당 스크립트가 실행 되도록 cron에 올려 사용 중임

그런데 ncloud 크레딧 사용 기간이 2개월 정도 남아서 크레딧을 다 소진하거나 기간이 끝나면 서버를 그대로 두거나 종료하거나

둘 중 하나인데,

서버를 그대로 두자니 요금이 부담되어서 고민 중인 찰나에 깃허브 액션을 이용해서 돈 안들이고(?) 커밋을 자동화할 수 있지 않을까 하는 생각이 들어 깃헙 액션을 사용한 커밋 자동화를 차근차근 진행 해보도록 하겠음.


🤷‍♂️ 진행 순서

  1. parsing.py 스크립트 작성
    • 블로그 글 파싱 기능 구현
  2. parsing.yaml 워크플로우 작성
    • 해당 워크플로우는 파싱이 우분투 서버내에서 정상적으로 실행 되는지 테스트하는 용도임
    • 파싱이 정상적으로 실행되면 해당 파일은 더 이상 사용되지 않음.
  3. 1,2번 진행 중 생기는 트러블슈팅 기록 작성
  4. test-parsing.py 스크립트 작성
    • parsing.py 단위 테스트 스크립트
  5. test-parsing.yaml 워크플로우 작성
    • 해당 워크플로우는 파싱 스크립트에 대한 단위 테스트용 워크플로우임
  6. blog-post.sh 쉘 스크립트 작성
  • 파싱 스크립트 실행 및 README.md 업데이트 하는 쉘 스크립트
  1. commit.sh 쉘 스크립트 작성
  • 변경사항 있는 파일들을 staging area에 add 및 commit 그리고 push 까지 하는 스크립트
  1. 최종적으로 blog-post.yaml 워크플로우 작성

📂 폴더 tree

일반 파일

$ tree .

.
├── OLD-README.md
├── README.md
├── contributions.svg
├── csv
│   └── parsing.csv
└── scripts
    ├── blog-post.sh
    ├── commit.sh
    ├── parsing.py
    ├── requirements.txt
    └── test
        ├── __init__.py
        └── test-parsing.py

.github/

tree .github/

.github/
└── workflows
    ├── blog-post.yaml
    ├── parsing.yaml
    └── test-parsing.yaml

✍️ 파싱 스크립트 작성

  • parsing.py
  • blog-post.sh
  • commit.sh
  • test-parsing.py : 요건 parsing.py 단위 테스트용

🔵 parsing.py

from typing import Dict,List
from bs4 import BeautifulSoup as bs
import requests as rq
import os  
import csv 

RESULTS = List[Dict[str,str]]

class Parser:
    def __init__(self,url:str) -> None:
        self.url = url
        self._headers : Dict[str,str] = {'user-agent': 'Mozilla/5.0'}
        
    @staticmethod
    def get_soup_object(res:rq.Response)-> bs:
        return bs(res.text,'html.parser')

    def parsing(self)-> RESULTS:
        # data on list
        save_list : RESULTS = []
        
        with rq.Session() as session:
            with session.get(url=self.url,headers=self._headers) as response:
                if response.ok:
                    soup : bs = self.get_soup_object(res=response)
                    
                    # Get content length
                    content_length : int = len(soup.select('li.list_horizontal_item',limit=5))
                    
                    for idx in range(content_length):
                        # data on dict
                        save_dict : Dict[str,str] = {}
                        
                        # Get Content
                        contents : list = soup.select('li.list_horizontal_item',limit=5)
                        
                        # 제목
                        title : str = contents[idx].select_one('strong.title_post').text.strip().replace(',','')
                        
                        # 날짜
                        dated : str = contents[idx].select_one('.date').text.strip().split(' ')[0].replace('.','-')

                        # 링크
                        link : str = "https://www.waytothem.com" + contents[idx].select_one('.link_article').attrs['href']
                        
                        save_dict['text'] = f"[{title}]({link}) -"
                        save_dict['dated'] = dated
                        save_list.append(save_dict)
                    return save_list     
            
class CSV(Parser):
    def __init__(self,url:str,save_path:str,file_name:str) -> None:
        super().__init__(url=url)
        self.save_path = save_path
        self.file_name = file_name

    def upload_data_to_csv(self)-> None:
        if not os.path.exists(self.save_path):
            os.mkdir(self.save_path)
        
        # Get results
        results : RESULTS = self.parsing()
        
        leng : List[str] = [v for v in range(len(results))]
        leng.reverse()        
        
        with open(self.file_name,'w',newline='') as fp:
            writer = csv.writer(fp)
            for r_idx in leng:
                writer.writerow([results[r_idx]['text'],results[r_idx]['dated']])
                print(f'{results[r_idx]}\n')

def main()-> None: 
    url : str = 'https://waytothem.com/blog/'
    
    save_path : str = 'csv/'
    file_name : str = os.path.join(save_path,'parsing.csv')

    # Create CSV instance
    csv : CSV = CSV(url=url,save_path=save_path,file_name=file_name)    
    csv.upload_data_to_csv()
    
if __name__ == '__main__':
    main()

🔵 test-parsing.py

  • parsing.py 에 대한 단위 테스트용 스크립트
from __init__ import Parser,CSV
from typing import Dict,List
import requests as rq
import os
import unittest
import shutil

class ParserTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        cls.parser : Parser = Parser(url='https://waytothem.com/blog/')
    
    def test_get_soup_object(self) -> None:
        response : rq.Response = rq.get(url=self.parser.url,headers=self.parser._headers)
        soup = self.parser.get_soup_object(res=response)
        title = soup.select_one('span.header__text').text.strip()
        self.assertEqual(title,"WTT's Blog")
    
    def test_parsing(self) -> None:
        results : List[Dict[str,str]] = self.parser.parsing()
        self.assertEqual(len(results),5)

class CSVTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        url = 'https://waytothem.com/blog/'
        cls.save_path = 'scripts/test-csv/'
        cls.file_name = os.path.join(cls.save_path,'test-parsing.csv')
        cls.csv : CSV = CSV(url=url,save_path=cls.save_path,file_name=cls.file_name)
    
    def tearDown(self) -> None:
        shutil.rmtree(self.save_path)        
    
    def test_upload_data_to_csv(self) -> None:
        self.csv.upload_data_to_csv()
        dir_true  : bool = os.path.isdir(self.save_path)
        file_true : bool = os.path.isfile(self.file_name)        
        self.assertTrue(dir_true)
        self.assertTrue(file_true)
        
if __name__ == '__main__':
    unittest.main()

🔵 blog-post.sh

  • 파싱해온 블로그 글 정제
    • 날짜 형식을 en_US 형식에 맞게 수정
  • time badge 업데이트 코드 작성
  • 커밋 스크립트 실행
#!/bin/bash

#### Set Python
PYTHON="$(which python3)"
# 

#### Set Files
PYTHON_FILE='./scripts/parsing.py'
COMMIT_FILE='./scripts/commit.sh'
#

#### Execute Python
"${PYTHON}" "${PYTHON_FILE}" 1>/dev/null && echo -e "Parsing Done\n"
#

#### Output save  ./line
SAVE_F='./line'
exec 3>> "${SAVE_F}"

echo -e "\n\n<!-- Blog-Post -->\n" >&3

#while IFS=','

while IFS=',' read text dated
do 
    formattedDate="$(LANG=en_US date '+%b %-d, %Y' -d ${dated})"
    echo "- ${text} ${formattedDate}" >&3
done < csv/parsing.csv

echo -e "\n<!-- Blog-Post End -->" >&3
#

#### Modified Time badge
D="$(date '+%Y/%m/%d_%H:%M')"
TIME_BADGE_URL="<img src=\"https://img.shields.io/badge/Last%20Modified-${D}-%23121212?style=flat\">"
echo -e "\n\n${TIME_BADGE_URL}" >&3
#

#### ADD README.md
cat ./OLD-README.md > ./README.md
cat "${SAVE_F}" >> README.md
#

#### line 파일 삭제
rm -rf "${SAVE_F}"
#

🔵 commit.sh

  • 커밋 스크립트
#!/bin/bash
if [[ $(git status --porcelain) ]]
then
    git config --global user.name "${USER_NAME}"
    git config --global user.email "${USER_EMAIL}"
    git remote set-url origin https://"${USER_NAME}":"${TOKEN}"@github.com/"${REPOSITORY}"
    git add -A
    git commit -m "${MESSAGE}"
    git push -u origin main
fi

✍️ 워크플로우 작성

  • parsing.yml
  • test-parsing.yml
  • blog-post.yml

🔵 parsing.yml

  • parsing.py 파싱 데이터 출력 값 확인 워크플로우
name: "Blog Contents Parsing"
on: [
  push:
    branches: [main]
  ]
jobs:
  build:
    name: "Blog Content Parsing Steps"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: 1. Install Dependencies
        run: |
          pip install --upgrade pip
          if [ -f ./scripts/requirements.txt ]
          then
            pip install -r ./scripts/requirements.txt
          else
            pip install bs4 requests
          fi
      - name: 2. parsing 스크립트 실행
        run:  python ./scripts/parsing.py
      - name: 3. csv/ 폴더 체크
        run: |
          if [ -d csv/ ]
          then
            echo "csv exists"
          fi
      - name: 4. parsing.csv 파일 체크
        run: |
          if [ -f csv/parsing.csv ]
          then
            cat csv/parsing.csv
          fi

🟡 build 결과

image

🔵 test-parsing.yml

  • 단위 테스트 성공 유/무 워크플로우
name: "parsing Script Unit Test"
on: [
  push:
    branches: [main]
  ]
jobs:
  unit-test:
    name: Parsing Script Unit Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: 1. Install dependencies
        run:  |
          pip install --upgrade pip
          if [ -f ./scripts/requirements.txt ]
          then
            pip install -r ./scripts/requirements.txt
          else 
            pip install requests bs4
          fi
      - name: 2. 단위 테스트 스크립트 실행
        run:  |
          python ./scripts/test/test-parsing.py

🟡 build 결과

image

🔵 blog-post.yml

name: Set recent blog post in my fucking README

on:
  push:
    branches: [main]

jobs:
  update-readme-blog-post:
    name: Update README with latest blog posts
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: 1. Install dependencies
        run:  |
          pip install --upgrade pip
          if [ -f ./scripts/requirements.txt ]
          then
            pip install -r ./scripts/requirements.txt
          else
            pip install bs4 requests
          fi
      - name: 2. Run Script
        run: |
          bash ./scripts/blog-post.sh
      - name: 3. Commit
        env:
          TOKEN: ${{ secrets.TOKEN }}
          REPOSITORY: ${{ github.repository }}
          MESSAGE: "Update blog post"
          USER_NAME: "JaehyoJJAng"
          USER_EMAIL: "yshrim12@naver.com"
        run: |
          bash ./scripts/commit.sh

🔴 옵션 분석


on

on:
  push:
    branches: [main]

이 Action이 실행되어야 하는 이벤트 정의. push 이벤트가 발생하고 브랜치가 main일 때, 해당 Action이 실행된다.


jobs

update-readme-blog-post:

Action이 실행될 Job을 정의. 여기서는 update-readme-blog-post 라는 하나의 Job을 정의하였음


runs-on

Job이 실행되는 환경 정의. 여기서는 ubuntu-latest로 정의


steps:
  - uses: actions/checkout@v3
  - name: 1. Install dependencies
    run:  |
      pip install --upgrade pip
      if [ -f ./scripts/requirements.txt ]
      then
        pip install -r requirements.txt
      else
        pip install bs4 requests
      fi
  - name: 2. Run Script
    run: |
      bash ./scripts/blog-post.sh || echo -e "🚨 Failed to update blog post\n\nCheck: https://www.waytothem.com/blog" && exit 1 
  - name: 3. Commit
    env:
      TOKEN:  secrets.TOKEN }
      REPOSITORY: ${{ github.repository }}
      MESSAGE: "Update blog post"
      USER_NAME: "JaehyoJJAng"
      USER_EMAIL: "yshrim12@naver.com"
    run: |
      bash ./scripts/commit.sh || echo -e "Failed to commit changes" && exit 1

각 단계는 다음과 같이 구성된다

  • actions/checkout@v3을 사용하여 저장소를 체크 아웃합니다.
  • 필요한 종속성을 설치합니다. 만약 ./scripts/requirements.txt 파일이 있다면, 해당 파일에 기재된 종속성을 설치하고, 없다면 BeautifulSoup4와 requests를 설치합니다. 그리고 ./scripts/blog-post.sh 스크립트를 실행합니다. 이때, 스크립트가 실패하면 메시지를 출력하고 exit code 1로 프로세스를 종료합니다.
  • commit.sh 스크립트를 사용하여 변경 내용을 커밋하고, 커밋 메시지는 "Update blog post"로 지정합니다. 이때, secrets.TOKEN으로 인증을 하고, 커밋을 수행한 사용자 이름과 이메일 주소를 "JaehyoJJAng"와 "yshrim12@naver.com"으로 설정합니다. 이 스크립트도 실패하면 메시지를 출력하고 exit code 1로 프로세스를 종료합니다.

여기서 사용된 각 옵션은 다음과 같습니다.

여기서 사용된 각 옵션은 다음과 같다

  • uses: actions/checkout@v3를 사용하여 저장소를 체크 아웃합니다.
  • run: shell 스크립트를 실행합니다.
  • if 조건문: 파일이 존재하는지 확인하여 조건문 실행을 결정합니다.
  • env: 환경 변수를 설정합니다. 해당 환경 변수들은 스크립트에서 참조할 수 있습니다.
  • exit: 프로세스를 종료합니다.
  • echo: 터미널에 메시지를 출력합니다.

🟡 build 결과

image


🔐 Github 환경변수

  • Actions Secret 변수 설정

Myrepo - Settings - Secrets and variables 탭에 들어가서

New repository secret 클릭 후 아래와 같이 워크플로우용 토큰 저장

image


🔴 트러블슈팅 기록

1. 첫번째 트러블슈팅

parsing.yml 파일을 작성하고 actions 빌드를 진행하면서 수 차례의 빌드 에러가 발생하였는데 그 중 대부분이 아래와 같은 내용의 에러였다.

No steps defined in `steps` and no workflow called in `uses` for the following jobs: build

위 에러가 발생 하였을 당시 parsing.yml 파일의 코드는 아래와 같았는데

name: "Blog Contents Parsing"
on: [push]
jobs:
  build:
    blog-parsing:
      name: "Blog Content Parsing Steps"
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v2
        - name: 1. Install Dependencies
          run: |
            pip install --upgrade pip
            if [ -f ./scripts/requirements.txt ]
            then
              pip install -r requirements.txt
            else
              pip install bs4 requests
            fi
        - name: 2. parsing 스크립트 실행
          run:  python

build 안에 또 빌드명을 지어넣으니 매칭되는 uses가 없다고 뜬 것 같다.

build: 요 부분을 blog-parsing 으로 바꾸고 기존에 있던 blog-parsing 을 삭제하니 정상적으로 actions가 빌드 되었다

아래 코드 참고

jobs:
  blog-parsing:
    name: "Blog Content Parsing Steps"
    ...
    ...

2. 두번째 트러블슈팅

blog-post.yaml 워크플로우를 빌드하면서 총 10건 정도의 빌드 에러가 발생하였고

발생 부분은 3. Commit 부분이었다.

에러 내용은 아래와 같다

3. Commit

... 
remote: Permission to JaehyoJJAng/commit-auto-github-action.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/JaehyoJJAng/commit-auto-github-action/': The requested URL returned error: 403
Failed to commit changes
Error: Process completed with exit code 1.

분명 깃헙 환경 변수에 토큰 값도 정상적으로 잘 들어가 있는데도 불구하고 왜 자꾸 에러가 나는지 이유를 몰라서

구글에 Permission to JaehyoJJAng/commit-auto-github-action.git denied to github-actions[bot]. 를 검색해서 해결방법을 찾아봤다.

찾아 본 결과 워크플로우에 대한 권한이 문제였다!

해결방법은 아래와 같다

You have to configure your repository - Settings -> Action -> General -> Workflow permissions and choose read and write permissions

image


Loading script...