﻿# 如何解決 GitHub Actions 的 Artifact storage 不夠用的問題

最近這幾個月因為寫了不少新的 GitHub Actions workflows，CI 頻繁執行的狀況下，導致這幾天開始出現了 `Artifact storage quota has been hit` 的錯誤訊息。經查詢後瞭解到，原來我的 GitHub Pro 訂閱，除了 GitHub Actions 每月 3,000 分鐘的執行時間額度外，還有每月 2 GB 的用量限制。但仔細查看 [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions) 文件之後，發現還真的非常複雜，這篇文章我打算來順一下計費的脈絡。

![Image](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/b6f3dd579dca5b3f0d7a-9a1df19a-341d-428c-b013-1c43387bcb6b.webp)

以下是我遇到的錯誤訊息：

> Error: Failed to CreateArtifact: Artifact storage quota has been hit. Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.  
> More info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending

### GitHub Actions 的計費方式

基本上，在使用 GitHub Actions 的時候，以下這兩種情境是完全免費的：

1.  使用自我託管的執行器 (self-hosted runners)
2.  你的專案是個公開儲存庫 (public repositories)，且使用標準 GitHub 託管的執行器 (GitHub-hosted runners)

對於**私人儲存庫** (private repositories)，每個 GitHub 帳戶將根據其購買的方案，可以獲得一定額度的**免費分鐘數**和**成品儲存空間**，用於 GitHub 託管的執行器，用量的計算大致分成以下兩類：

1.  執行**分鐘數** (每月重置)
    
    **分鐘數**是根據當月在每種 **執行器** (runner) 類型上使用的**總處理時間**計算。
    
2.  使用**儲存空間用量** (每月不會重置)
    
    儲存空間是根據當月**成品**(artifact)的**小時使用量**計費。
    
    儲存空間用量的計算方式非常複雜，而且文件也講的很難理解，更沒有後台可以讓你快速查看用量，其實不好懂。GitHub 並不是給你一個容量的上限，而是所謂 `GB-Hours` 的概念，再除以每月 744 小時。
    

預設的情況下，超出額度限制的任何使用量，會直接不能使用，不會直接向用戶收費，但你可以到 [Budgets and alerts](https://docs.github.com/en/billing/concepts/budgets-and-alerts) 調整預算限制與警示通知。

### 分鐘數的計算方式

任何時候 CI / CD 要執行 Pipeline 時，都需要 Runner (執行器) 才能執行程式，當你使用了標準 GitHub 託管的執行器 (GitHub-hosted runners) 在「私有專案」(private repo)，就會記錄你每次的「執行分鐘數」。

不過，**執行器** (Runner) 執行 1 分鐘，不代表會扣除你的每月免費額度 3,000 分鐘的 1 分鐘。有時候執行 1 分鐘，會直接扣除 2 分鐘額度。有時候執行 1 分鐘，會直接扣除 10 分鐘額度。這樣的機制我們稱為「分鐘數乘數」(minute multipliers)。

-   若選用 Linux runner，乘數為 1 倍
    
    如果用 Linux 的 Runner 跑 10 分鐘，就會以 10 分鐘來計算。
    
-   若選用 Windows runner，乘數為 2 倍
    
    如果用 Windows 的 Runner 跑 10 分鐘，就會以 20 分鐘來計算。
    
-   若選用 macOS runner，乘數為 10 倍
    
    如果用 macOS 的 Runner 跑 10 分鐘，就會以 100 分鐘來計算。
    

> 如需 GitHub 託管執行器的分鐘數乘數的完整詳細資訊，請參閱 [Actions 分鐘數乘數參考](https://docs.github.com/en/billing/reference/actions-minute-multipliers)。

### 儲存空間用量的計算方式

我用以下例子來說明儲存空間的成本計算方式。

假設你在這個月使用 2 GB 的儲存空間 20 天，並使用 1 GB 的儲存空間 10 天，則您的儲存空間使用量將為：

-   2GB x 20 天 x (每天 24 小時) = 960 GB-Hours
-   3GB x 10 天 x (每天 24 小時) = 720 GB-Hours

當月總 `GB-Hours` 為 960 + 720 = 1680 GB-Hours

當月總 `GB-Month` 為 1680 GB-Hours / 744 Hours-per-month = `2.25 GB`

如果你不小心還沒到月底就用超過限制了，要等**下個月初**才會重新開始計算，我覺得非常不便！

以我的帳號的 Action storage 上限為 2GB 為例，不用到月底，你的 Action storage 就無法再寫入了：

![image](https://stwillblogassets.blob.core.windows.net/files/images/external/github.com/aa9a1d209c04451a5f4a-05a10ab3-8eaa-4c16-b3d7-dd5c09b77601.png)

如果你有允許超量使用的話，月底時，GitHub 會將您的儲存空間四捨五入到最接近的 MB，並自動扣款。

這裡必須要注意的是，GitHub 會在每 6 到 12 小時的視窗內更新您的儲存空間用量，但你並不知道何時會計算你當前的用量，這部分是個大黑箱。

假設你的計算用量的時間是晚上 00:00 的話，你在 01:00 寫入了 1GB 的檔案到 artifact storage，但是在 03:00 的時候把 artifact storage 刪除，那麼到 06:00 的時候，就不會計算到你的 `GB-Hours` 之內。

### 如何刪除 Action storage 佔用的空間

很可惜的，在 GitHub 上面完全沒有任何 UI 可以看到 Action storage 佔用的空間，而且你的 Action storage 用量每個 Private Repo 都有可能會用到，所以只能透過 [GitHub REST API](https://docs.github.com/en/rest) 取得用量資訊，然後逐一刪除過期的 workflow runs 或 artifacts 才有機會釋出空間。

因此，我用 PowerShell 寫了一份腳本，可以快速刪除 GitHub Actions 的 artifacts 與 workflow runs！

只要建立 `$HOME\Documents\PowerShell\Scripts\Cleanup-GitHubActions.ps1` 檔案：

```powershell
<#
.SYNOPSIS
  清除指定 GitHub Repo 中早於指定天數的 GitHub Actions artifacts 與 workflow runs。

.DESCRIPTION
  以 GitHub CLI gh 調用 REST API。
  先刪 artifacts 再刪 workflow runs。支援 -WhatIf 與 -Confirm。

.PARAMETER Owner
  擁有者帳號。例如: doggy8088

.PARAMETER Repo
  倉庫名稱。例如: playwright.tw

.PARAMETER DaysToKeep
  保留天數。早於此天數的資料會被刪除。預設 14。

.PARAMETER PerPage
  單頁抓取數量。預設 100。

.PARAMETER ArtifactsOnly
  只處理 artifacts。

.PARAMETER RunsOnly
  只處理 workflow runs。

.EXAMPLE
  .\Cleanup-GHA.ps1 -Owner doggy8088 -Repo playwright.tw -DaysToKeep 14 -WhatIf

.EXAMPLE
  .\Cleanup-GHA.ps1 -Owner org -Repo repo -Confirm
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
  [Parameter(Mandatory = $true)][string]$Owner,
  [Parameter(Mandatory = $true)][string]$Repo,
  [int]$DaysToKeep = 14,
  [int]$PerPage    = 100,
  [switch]$ArtifactsOnly,
  [switch]$RunsOnly
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Test-Gh {
  if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
    throw "找不到 GitHub CLI gh。請安裝後重試。"
  }
  try {
    gh auth status | Out-Null
  } catch {
    throw "gh 尚未登入或權限不足。請執行 gh auth login 後重試。"
  }
}

function Get-Cutoff {
  param([int]$KeepDays)
  return [DateTimeOffset]::UtcNow.AddDays(-$KeepDays)
}

function Remove-OldArtifacts {
  param(
    [string]$BasePath,
    [DateTimeOffset]$Cutoff,
    [int]$PerPage
  )
  $page = 1
  $deleted = 0
  do {
    try {
      $resp = gh api "$BasePath/artifacts?per_page=$PerPage&page=$page" | ConvertFrom-Json
    } catch {
      Write-Warning "取得 artifacts 清單失敗，page=$page。錯誤: $($_.Exception.Message)"
      break
    }

    $items = @()
    if ($resp -and $resp.artifacts) { $items = @($resp.artifacts) }

    foreach ($a in $items) {
      $created = [DateTimeOffset]::Parse($a.created_at)
      if ($created -lt $Cutoff) {
        $target = "artifact id=$($a.id) name=$($a.name) created_at=$($a.created_at) run_id=$($a.workflow_run.id)"
        if ($PSCmdlet.ShouldProcess($target, "Delete")) {
          try {
            gh api --method DELETE "$BasePath/artifacts/$($a.id)" | Out-Null
            $deleted++
            Write-Host "Deleted $target"
          } catch {
            Write-Warning "刪除 artifact 失敗 id=$($a.id) name=$($a.name) 錯誤: $($_.Exception.Message)"
          }
        } else {
          Write-Host "Would delete $target"
        }
      }
    }
    $page++
  } while ($items.Count -gt 0)

  return $deleted
}

function Remove-OldRuns {
  param(
    [string]$BasePath,
    [DateTimeOffset]$Cutoff,
    [int]$PerPage
  )
  $page = 1
  $deleted = 0
  do {
    try {
      $resp = gh api "$BasePath/runs?per_page=$PerPage&page=$page" | ConvertFrom-Json
    } catch {
      Write-Warning "取得 workflow runs 清單失敗，page=$page。錯誤: $($_.Exception.Message)"
      break
    }

    $items = @()
    if ($resp -and $resp.workflow_runs) { $items = @($resp.workflow_runs) }

    foreach ($r in $items) {
      $created = [DateTimeOffset]::Parse($r.created_at)
      if ($created -lt $Cutoff) {
        $target = "run id=$($r.id) name=$($r.name) created_at=$($r.created_at)"
        if ($PSCmdlet.ShouldProcess($target, "Delete")) {
          try {
            gh api --method DELETE "$BasePath/runs/$($r.id)" | Out-Null
            $deleted++
            Write-Host "Deleted $target"
          } catch {
            Write-Warning "刪除 workflow run 失敗 id=$($r.id) name=$($r.name) 錯誤: $($_.Exception.Message)"
          }
        } else {
          Write-Host "Would delete $target"
        }
      }
    }
    $page++
  } while ($items.Count -gt 0)

  return $deleted
}

# 主流程
if ($ArtifactsOnly -and $RunsOnly) {
  throw "-ArtifactsOnly 與 -RunsOnly 不可同時使用。"
}

Test-Gh

$base   = "repos/$Owner/$Repo/actions"
$cutoff = Get-Cutoff -KeepDays $DaysToKeep

Write-Host "將刪除建立於 $DaysToKeep 天之前的 artifacts 與 workflow runs"
Write-Host "時間門檻 UTC: $($cutoff.UtcDateTime.ToString('u'))"
Write-Host ""

$totalArtifacts = 0
$totalRuns      = 0

if (-not $RunsOnly) {
  $totalArtifacts = Remove-OldArtifacts -BasePath $base -Cutoff $cutoff -PerPage $PerPage
  Write-Host ""
  Write-Host "共刪除 artifacts: $totalArtifacts"
  Write-Host ""
}

if (-not $ArtifactsOnly) {
  $totalRuns = Remove-OldRuns -BasePath $base -Cutoff $cutoff -PerPage $PerPage
  Write-Host ""
  Write-Host "共刪除 workflow runs: $totalRuns"
}

# 結束碼: 有刪除則 0，否則 0（不視為錯誤）
exit 0
```

使用方式如下，記得將 `USER` 與 `REPO` 換成你自己的 GitHub 帳號與 Repo 名稱：

1.  刪除超過 14 天的所有 artifacts 與 workflow runs
    
    ```powershell
    Cleanup-GitHubActions -Owner USER -Repo REPO
    ```
    
    你也可以明確加入 `-DaysToKeep` 指定保留的天數 (預設為 14 天)
    
    ```powershell
    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 14
    ```
    
2.  刪除所有 artifacts 與 workflow runs
    
    ```powershell
    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 0
    ```
    
3.  僅刪除 artifacts 且保留 workflow runs 所有的 Summary 與 Logs
    
    ```powershell
    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 0 -ArtifactsOnly
    ```
    
4.  僅刪除 workflow runs (此舉將會連同 artifacts 一起全數刪除)
    
    ```powershell
    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 0 -RunsOnly
    ```
    

### 快速統計 Action storage 用量的 Bash 腳本

我寫了一支 Bash 腳本，可以快速分析個人帳號下所有的 `private` repo 佔用了多少 Action storage 儲存空間，方便你加總並騰出空間來用！👍

```bash
#!/bin/bash
if ! gh auth status > /dev/null 2>&1; then
  echo "Please authenticate with the GitHub CLI using 'gh auth login'."
  exit 1
fi

# Function to show usage
usage() {
  echo "Usage: $0 [repo_type] [--debug]"
  echo "repo_type can be one of: public, private, all. Default is private."
  echo "Example: $0 private --debug"
  exit 1
}

# Default values
REPO_TYPE="private"
DEBUG=false
POSITIONAL_ARGS=()

# Parse arguments
while [[ $# -gt 0 ]]; do
  case $1 in
    --debug)
      DEBUG=true
      shift # past argument
      ;;
    *)
      POSITIONAL_ARGS+=("$1") # save positional arg
      shift # past argument
      ;;
  esac
done

# Restore positional arguments
set -- "${POSITIONAL_ARGS[@]}"

# repo_type is the first positional argument, if present
if [ -n "$1" ]; then
    REPO_TYPE=$1
fi

if [[ "$REPO_TYPE" != "public" && "$REPO_TYPE" != "private" && "$REPO_TYPE" != "all" ]]; then
  echo "Invalid repo_type. Please choose 'public', 'private', or 'all'."
  usage
fi

echo "Fetching your repositories..."

PAGE=1
TOTAL_ARTIFACT_SIZE=0
TOTAL_CACHE_SIZE=0

while true; do
  API_URL="/user/repos?affiliation=owner&visibility=$REPO_TYPE&per_page=100&page=$PAGE"

  if [ "$DEBUG" = true ]; then
    echo "DEBUG: gh api -H \"Accept: application/vnd.github+json\" \"$API_URL\""
    echo "DEBUG: Repositories on page $PAGE:"
  fi
  REPOS_RESPONSE=$(gh api -H "Accept: application/vnd.github+json" "$API_URL")

  REPOS=$(echo "$REPOS_RESPONSE" | jq -r '.[].full_name')
  if [ -z "$REPOS" ]; then
    break
  fi

  REPOS_COUNT=$(printf '%s\n' "$REPOS" | grep -c .)
  echo "🟢 Repositories on page $PAGE: $REPOS_COUNT items"

  if [ "$DEBUG" = true ]; then
    echo "DEBUG: Repositories on page $PAGE:"
    while IFS= read -r repo; do
      [ -n "$repo" ] && echo "DEBUG:   $repo"
    done <<< "$REPOS"
  fi
  #echo "$REPOS"

  for REPO in $REPOS; do
    echo "⚪ Processing repository: $REPO"
    REPO_PAGE=1
    REPO_ARTIFACT_SIZE=0

    while true; do
      API_URL="/repos/$REPO/actions/artifacts?per_page=100&page=$REPO_PAGE"
      if [ "$DEBUG" = true ]; then
        echo "DEBUG: gh api -H \"Accept: application/vnd.github+json\" \"$API_URL\""
      fi
      RESPONSE=$(gh api -H "Accept: application/vnd.github+json" "$API_URL")
      SIZES=$(echo "$RESPONSE" | jq '.artifacts[].size_in_bytes' 2>/dev/null)
      if [ -z "$SIZES" ]; then
        break
      fi

      for SIZE in $SIZES;
      do
        REPO_ARTIFACT_SIZE=$((REPO_ARTIFACT_SIZE + SIZE))
      done
      HAS_NEXT_PAGE=$(echo "$RESPONSE" | jq '.artifacts | length == 100')
      if [ "$HAS_NEXT_PAGE" != "true" ]; then
        break
      fi

      REPO_PAGE=$((REPO_PAGE + 1))
    done
    API_URL="/repos/$REPO/actions/cache/usage"
    if [ "$DEBUG" = true ]; then
      echo "DEBUG: gh api -H \"Accept: application/vnd.github+json\" \"$API_URL\""
    fi
    CACHE_RESPONSE=$(gh api -H "Accept: application/vnd.github+json" "$API_URL")

    REPO_CACHE_SIZE=$(echo "$CACHE_RESPONSE" | jq '.active_caches_size_in_bytes // 0')
    TOTAL_ARTIFACT_SIZE=$((TOTAL_ARTIFACT_SIZE + REPO_ARTIFACT_SIZE))
    TOTAL_CACHE_SIZE=$((TOTAL_CACHE_SIZE + REPO_CACHE_SIZE))

    if [ "$REPO_ARTIFACT_SIZE" -ne 0 ] || [ "$REPO_CACHE_SIZE" -ne 0 ]; then
      REPO_ARTIFACT_SIZE_MB=$(echo "scale=2; $REPO_ARTIFACT_SIZE / 1024 / 1024" | bc)
      REPO_CACHE_SIZE_MB=$(echo "scale=2; $REPO_CACHE_SIZE / 1024 / 1024" | bc)
      echo "ℹ Total artifact size for $REPO: $REPO_ARTIFACT_SIZE_MB MB"
      echo "ℹ Total cache size for $REPO: $REPO_CACHE_SIZE_MB MB"
    fi
  done
  HAS_NEXT_PAGE=$(echo "$REPOS_RESPONSE" | jq 'length == 100')
  if [ "$HAS_NEXT_PAGE" != "true" ]; then
    break
  fi

  PAGE=$((PAGE + 1))
done
TOTAL_ARTIFACT_SIZE_MB=$(echo "scale=2; $TOTAL_ARTIFACT_SIZE / 1024 / 1024" | bc)
TOTAL_CACHE_SIZE_MB=$(echo "scale=2; $TOTAL_CACHE_SIZE / 1024 / 1024" | bc)

echo "========================================"
echo "Total artifact size across all repositories: $TOTAL_ARTIFACT_SIZE_MB MB"
echo "Total cache size across all repositories: $TOTAL_CACHE_SIZE_MB MB"
```

使用方式如下：

1.  統計 private repos 的空間用量
    
    ```bash
    check-artifacts-usage.sh private
    ```
    
    預設值就是 `private`，所以直接執行也可以：
    
    ```bash
    check-artifacts-usage.sh
    ```
    
2.  統計 public repos 的空間用量
    
    ```bash
    check-artifacts-usage.sh public
    ```
    
3.  統計 all repos 的空間用量
    
    ```bash
    check-artifacts-usage.sh all
    ```
    

### 相關連結

-   [GitHub Actions billing - GitHub Docs](https://docs.github.com/en/billing/concepts/product-billing/github-actions#included-storage-and-minutes)
-   [GitHub REST API](https://docs.github.com/en/rest)
    -   [REST API endpoints for GitHub Actions artifacts](https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28)
    -   [REST API endpoints for workflow runs](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28)
-   [Monitor your GitHub Actions storage usage with a script](https://www.eliostruyf.com/monitor-github-actions-storage-usage-script/)
