Solve the problem of the redundant artifacts in Github Actions 清除 Github Actions 中多餘的 Artifacts
2020-02-27 01:23:00
2.5k words / 11-minute read
目錄 Table of contents
  1. 前言
  2. 目標
  3. 操作Artifacts的API
  4. 清除腳本
  5. 整合Github Actions
    1. 將Token加入Secrets
    2. Shell Script
    3. Action Config(YAML)
    4. 先看看成果
    5. 添加Purging Action
  6. 成果
  7. 專案網址
  8. 參考網站

從0.8G以上的危機降到了0.26G

前言

這篇接續上一篇 CI in Unity using Github Actions 使用Github Actions自動化測試建置Unity專案 後面所提及的容量限制問題。

上次說過,依照原本的方法大概build不用10次,Github容量就會爆了。
於是大概在第7次的時候就會收到這種信:

恭喜你即將用爆囉,請快點升級方案

我是收到了這封信的時候才驚覺原來github有容量限制,之前一直以為可以隨便我塞到飽,或許寫個接口還能當作雲端硬碟使用(?)

總之收到這封信以後我才開始正視這個問題,好險是有找到解決方案,不然我絕對會放棄使用Github Actions作為重要專案的CI服務。

我只是單純記錄解決的過程,文筆會有些雜亂且廢話居多。

目標

目標很簡單,需要可以自動刪除過期多餘的artifacts的方法。

過期多餘的定義也很單純,我只要保留前N個版本的執行檔/輸出檔,其餘就是多餘且過期的。

操作Artifacts的API

Github的REST API v3有提供這樣的API,參考這裡

  • List all artifact of the repo
    GET /repos/:owner/:repo/actions/artifacts
  • Delete specific artifact
    DELETE /repos/:owner/:repo/actions/artifacts/:artifact_id

以我上次的專案為例,想取得所有artifacts的資訊可以直接連到

https://api.github.com/repos/qwe321qwe321qwe321/Unity-with-Github-Actions-example/actions/artifacts

來查看。

可以看到這裡有36個artifacts

有了這些API就好解決了,可以寫個腳本來爬artifacts的資訊再進行刪除。

清除腳本

本來想自己寫,但結果不用。查到這篇發現有人已經寫好了!再次感謝 lelegard 這位仁兄。

他的gist: https://gist.github.com/lelegard/6a428f67ee08e86d0c2f1af3f4a633d0

使用前記得dependencies要裝一裝,主要就jq要另外裝而已,也就apt-get install jq解決。

但是我用了一下發現一個小bug: 遇到名稱包含空白的artifact時print會噴錯,
於是就fork順便幫他改了一下,以下是修改後的版本:

#!/usr/bin/env bash

# Customize those three lines with your repository and credentials:
REPO=https://api.github.com/repos/OWNER/REPO
GITHUB_USER=your-github-user-name
GITHUB_TOKEN=token-with-workflow-rights-on-repo # 這個token需要repo相關的權限

# Number of most recent versions to keep for each artifact:
KEEP=5 # 保留的前N新的版本數量

# A shortcut to call GitHub API.
ghapi() { curl --silent --location --user $GITHUB_USER:$GITHUB_TOKEN "$@"; }

# A temporary file which receives HTTP response headers.
TMPFILE=/tmp/tmp.$$

# An associative array, key: artifact name, value: number of artifacts of that name.
declare -A ARTCOUNT

# Process all artifacts on this repository, loop on returned "pages".
URL=$REPO/actions/artifacts
while [[ -n "$URL" ]]; do

    # Get current page, get response headers in a temporary file.
    JSON=$(ghapi --dump-header $TMPFILE "$URL")

    # Get URL of next page. Will be empty if we are at the last page.
    URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*<//' -e 's/>.*//')
    rm -f $TMPFILE

    # Number of artifacts on this page:
    COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) # 這裡是取得artifact的數量

    # Loop on all artifacts on this page.
    for ((i=0; $i < $COUNT; i++)); do
		
        # Get name of artifact and count instances of this name.
        name=$(jq <<<$JSON -r ".artifacts[$i].name?") # 這裡是取得第i個artifact的名稱
        ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) # 以名稱為key,儲存目前有多少個相同名稱的artifacts
		#printf "#%d %s - %d\n" $i "$name" ${ARTCOUNT[$name]}
        # Check if we must delete this one.
        if [[ ${ARTCOUNT[$name]} -gt $KEEP ]]; then #若超過$KEEP的量則刪除
            id=$(jq <<<$JSON -r ".artifacts[$i].id?") # 取得id以便得到url
            size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") ))
            printf "Deleting %s #%d, %d bytes\n" "$name" ${ARTCOUNT[$name]} $size
            ghapi -X DELETE $REPO/actions/artifacts/$id # DELETE API
        fi
    done
done

參數設定好後直接運行即可清除多餘的Artifacts。

注意DELETE API需要額外的存取repo權限,所以需要去申請一個personal access token,或是直接用上一篇發送repository_dispath用的那組token也行。

整合Github Actions

有了腳本以後,再來就需要把腳本整合到Action內,最好是在完成上傳artifact以後執行清除腳本。

將Token加入Secrets

因為我們不希望直接明碼保存personal token在repo裡,加上github也不會讓你這麼做(當你上傳token明碼時會自動撤銷該token)。

所以我們需要把token放到repo的secrets中,之後再從action config裡傳入shell script。

(2020/03/01更新) 不需要手動做這個步驟,Github會自動產生「該repo的讀寫相關權限之token」在Secrets中命名為GITHUB_TOKEN(雖然你從web上看不到),詳細請參考這篇官方文件

Shell Script

再次修改剛剛上面提供的腳本,由於我希望參數化都在action的config檔裡面處理就好,而這個腳本不要帶有任何參數,所以修改一下腳本,如下。

#!/usr/bin/env bash

# Customize those three lines with your repository and credentials:
# Split the string "username/repo" into two parts.
GITHUB_USER="$(cut -d'/' -f1 <<< $OWNER_AND_REPO)"
GITHUB_REPO="$(cut -d'/' -f2 <<< $OWNER_AND_REPO)"
GITHUB_TOKEN=$PERSONAL_TOKEN
REPO=https://api.github.com/repos/$GITHUB_USER/$GITHUB_REPO

# Number of most recent versions to keep for each artifact:
KEEP=$KEEPING_COUNT

echo USER: $GITHUB_USER
echo REPO: $GITHUB_REPO
echo Keep: $KEEP

# A shortcut to call GitHub API.
ghapi() { curl --silent --location --user $GITHUB_USER:$GITHUB_TOKEN "$@"; }

# A temporary file which receives HTTP response headers.
TMPFILE=/tmp/tmp.$$

# An associative array, key: artifact name, value: number of artifacts of that name.
declare -A ARTCOUNT

# Process all artifacts on this repository, loop on returned "pages".
URL=$REPO/actions/artifacts
while [[ -n "$URL" ]]; do

    # Get current page, get response headers in a temporary file.
    JSON=$(ghapi --dump-header $TMPFILE "$URL")

    # Get URL of next page. Will be empty if we are at the last page.
    URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*<//' -e 's/>.*//')
    rm -f $TMPFILE

    # Number of artifacts on this page:
    COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') ))
	
	echo There are $COUNT artifacts in $OWNER_AND_REPO

    # Loop on all artifacts on this page.
    for ((i=0; $i < $COUNT; i++)); do
		
        # Get name of artifact and count instances of this name.
        name=$(jq <<<$JSON -r ".artifacts[$i].name?")
        ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1))
		#printf "#%d %s - %d\n" $i "$name" ${ARTCOUNT[$name]}
        # Check if we must delete this one.
        if [[ ${ARTCOUNT[$name]} -gt $KEEP ]]; then
            id=$(jq <<<$JSON -r ".artifacts[$i].id?")
            size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") ))
            printf "Deleting %s #%d, %d bytes\n" "$name" ${ARTCOUNT[$name]} $size
            ghapi -X DELETE $REPO/actions/artifacts/$id
        fi
    done
done

第5、6行是字串切割,把"owner/repo"的字串切成owner和repo兩個,從這篇找到如何在shell切割字串。之所以要這樣搞是因為action config那邊只能取得"owner/repo"這樣的字串資料。

有了這個腳本後,我把它放到.github/workflows/purging-artifacts.sh,因為它只跟actions有依賴關係,我不希望它出現在我的repo首頁或其他地方。

Action Config(YAML)

多做一個job如下。

purgeArtifacts: # 清除多餘的artifacts
  needs: [testAllModes] # 等到全部人跑完以後
  name: Purging the redundant artifacts.
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v2 # checkout方法用來clone本專案(repo)到裝置內。
      with:
        lfs: true # 要不要下載git-lfs檔,即大型檔案。
    - name: Purging the redundant artifacts.
      run: | # 這個|符號表示多行輸入
        chmod +x ./.github/workflows/purging-artifacts.sh # 改執行權限
        ./.github/workflows/purging-artifacts.sh # 跑寫好的shell
      env: 
        PERSONAL_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 將GITHUB_TOKEN設定為環境變數
        KEEPING_COUNT: 1 # 保存前N新的artifacts 
        OWNER_AND_REPO: ${{ github.repository }} # The string combine owner and repository. For example, Codertocat/Hello-World.

我把這段放在Tester.yml的testAllMode之後,詳情請看原始碼 (←但是注意這是舊版的原始碼,我還手動塞了個PERSONAL_TOKEN到Secrets裡面,實際上可以直接用GITHUB_TOKEN)。

先看看成果

把剛剛修改的成果push到一個新分支(branch),只讓它觸發tester事件。

之後我們再看一次artifacts列表:

只剩8個artifacts

分別是:

  • Test results for playmode
  • Test results for editmode
  • StandaloneWindows64 Build
  • StandaloneOSX Build
  • StandaloneLinux64 Build
  • Unity_v2018.4.13f1.alf

蛤?明明只有6個。

其實是Test result都各多了一個,這不算是我預期的結果。猜想應該是當下runner那次的結果還沒更新到artifacts list上,所以purging的時候還沒有它們,因此才會多出來一組。

這樣的話也算好解決,目前想到兩個方法:

  1. 多傳個當前runner生成的artifact名稱傳給purging script檢測,將該名稱的數量額外減1。
  2. 把purging的步驟移至單獨一個action,該action必須檢測其它所有actions都跑完了才執行。

第2個應該比較好,因為purging畢竟是全部檢查全部清掃的script,理論上test+build之後只要最後運行一次就好了,這樣也不用跑好幾次沒意義的步驟了。

但是,目前先不花時間測試這些方法,現在這樣已經解決1G容量限制的大麻煩了。

我選擇第2個方法來修正這個問題。

添加Purging Action

我在Github上找到了 github-action-wait-for-status,它可以等待其它Actions跑完後取得status(success/failure)才繼續執行下一個step,這樣我就可以利用這個方法做Purging Artifacts。

但是它目前release版本只有0.1.0且star不多,感覺還不是很穩定,斟酌使用

  1. 把原本的Tester.yml改回原樣。
  2. 添加新的config至.github/workflows/purging-artifacts.yml,內容如下:
    name: Auto Purging Artifacts
    on: 
          push: { } # 任意push觸發(配合Tester用)
          pull_request: { branches: [master] } # 僅在pull request to master時觸發(配合Builder使用)
       jobs:
      autoPurge:
        runs-on: ubuntu-latest
        steps:
          - name: 'Wait for status checks'
            id: waitforstatuschecks
            uses: "WyriHaximus/github-action-wait-for-status@0.1.0"
            with:
                  ignoreActions: autoPurge # 略過檢查自己
                  checkInterval: 13 # 每13秒檢查一次
                env:
              GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
          - uses: actions/checkout@v2 # checkout方法用來clone本專案(repo)到裝置內。
          - name: Purging the redundant artifacts.
            run: | # 這個|符號表示多行輸入
              chmod +x ./.github/workflows/purging-artifacts.sh # 改執行權限
              ./.github/workflows/purging-artifacts.sh # 跑寫好的shell
            env: 
              PERSONAL_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 將GITHUB_TOKEN設定為環境變數
              KEEPING_COUNT: 1 # 保存前N新的artifacts 
              OWNER_AND_REPO: ${{ github.repository }} # The string combine owner and repository. For example, Codertocat/Hello-World.
  3. 完成,commit並push上去。

成果

等跑完action後,再打開一次artifacts列表。

剩6個了

這樣就正確無誤了,同名的artifacts只保留最新的一份。

這樣應該算是成功透過保留最少的檔案來避免塞爆1G容量限制了。

專案網址

Github: https://github.com/qwe321qwe321qwe321/Unity-with-Github-Actions-example/tree/purging-artifacts

參考網站