🌏 개요
현재 Ncloud 서버를 무료 크레딧으로 임대하고 있고
파이썬, 쉘 스크립트를 기반으로 1일 1커밋 자동화 코드를 작성하여
서버에서 매일 23:30분에 해당 스크립트가 실행 되도록 cron에 올려 사용 중임
그런데 ncloud 크레딧 사용 기간이 2개월 정도 남아서 크레딧을 다 소진하거나 기간이 끝나면 서버를 그대로 두거나 종료하거나
둘 중 하나인데,
서버를 그대로 두자니 요금이 부담되어서 고민 중인 찰나에 깃허브 액션을 이용해서 돈 안들이고(?) 커밋을 자동화할 수 있지 않을까 하는 생각이 들어 깃헙 액션을 사용한 커밋 자동화를 차근차근 진행 해보도록 하겠음.
🤷♂️ 진행 순서
- parsing.py스크립트 작성- 블로그 글 파싱 기능 구현
 
- parsing.yaml워크플로우 작성- 해당 워크플로우는 파싱이 우분투 서버내에서 정상적으로 실행 되는지 테스트하는 용도임
- 파싱이 정상적으로 실행되면 해당 파일은 더 이상 사용되지 않음.
 
- 1,- 2번 진행 중 생기는 트러블슈팅 기록 작성
- test-parsing.py스크립트 작성- parsing.py단위 테스트 스크립트
 
- test-parsing.yaml워크플로우 작성- 해당 워크플로우는 파싱 스크립트에 대한 단위 테스트용 워크플로우임
 
- blog-post.sh쉘 스크립트 작성
- 파싱 스크립트 실행 및 README.md 업데이트 하는 쉘 스크립트
- commit.sh쉘 스크립트 작성
- 변경사항 있는 파일들을 staging area에 add 및 commit 그리고 push 까지 하는 스크립트
- 최종적으로 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 결과

🔵 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 결과

🔵 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-onJob이 실행되는 환경 정의. 여기서는 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 결과

🔐 Github 환경변수
- Actions Secret 변수 설정
Myrepo - Settings - Secrets and variables 탭에 들어가서
New repository secret 클릭 후 아래와 같이 워크플로우용 토큰 저장

🔴 트러블슈팅 기록
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:  pythonbuild 안에 또 빌드명을 지어넣으니 매칭되는 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

