
但凡用 GitHub Actions 推過 Docker 鏡像的人大概率都撞到過這個報錯denied: permission_denied: write_package或者是推送過程中突然冒出一句unexpected status from POST request to https://ghcr.io/v2/xxx/xxx/blobs/uploads/: 403 Forbidden第一次遇到的時候我盯著日志看了半天login 明明成功了build 也沒問題偏偏到 push 那一步就給你卡死而且返回的還是“權限不足”這種讓人摸不著頭腦的話。后來翻了不少資料、踩了幾個坑才把這里的權限鏈路徹底捋順。這篇文章就把 write_package 這個報錯的來龍去脈、權限模型、完整修復方案和排查套路一次性講清楚。無論你是剛接觸 GitHub Actions 的新手還是已經(jīng)推過一段時間鏡像但偶爾被它絆一下的老手按下面的步驟走一遍基本能徹底擺脫這個問題。1. 現(xiàn)象還原write_package 報錯到底是什么1.1 報錯現(xiàn)場與最小復現(xiàn)先看一個最典型的、能穩(wěn)定復現(xiàn)這個錯誤的 workflow 配置。很多項目一開始就是這么寫的name: build-and-push on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Log in to GitHub Container Registry run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Build and push run: | docker build -t ghcr.io/${{ github.repository_owner }}/demo:v1 . docker push ghcr.io/${{ github.repository_owner }}/demo:v1這段配置看著沒毛病實際上跑起來就會在docker push那一步報權限錯誤。報錯通常長這樣ERROR: failed to push ghcr.io/xxx/demo:v1: denied: permission_denied: write_package還有另一種形態(tài)是 buildx 在推多個層的時候報ERROR: failed to push ghcr.io/xxx/demo:v1: unexpected status from POST request to https://ghcr.io/v2/xxx/demo/blobs/uploads/: 403 Forbidden兩種報錯的根因其實一樣當前使用的憑據(jù)對目標鏡像所在的 GitHub Packages 包沒有寫入權限。1.2 為什么“看起來沒問題”卻一直失敗很多人的第一反應是“Docker Hub 都能推ghcr.io 怎么就不行”。這其實是把 GitHub Packages 和 Docker Hub 的鑒權模型搞混了。Docker Hub 的 push 權限是跟著你的賬號走的只要登錄的是有權限的賬號推哪個倉庫基本都能通過。但 GitHub Packages 的權限校驗比它多了一層它不僅要驗證“你是誰”還要驗證“你對這個包有沒有寫入權限”。這里的“包”指的是 ghcr.io 上的鏡像倉庫package它的歸屬、可見性、寫入權限和 GitHub 倉庫的權限體系是深度綁定的。也就是說如果你用的是GITHUB_TOKEN它的權限范圍由 workflow 的permissions設置決定如果沒有顯式聲明packages: write默認情況下這個 token 只能讀不能寫一旦 push 請求發(fā)出去GitHub Packages 發(fā)現(xiàn) token 沒有寫權限就直接返回write_package這個錯誤碼。所以問題根本不是 docker login 失敗而是登錄成功后token 的權限標簽不夠。這就像你拿著門禁卡進了大樓但電梯權限沒開按了樓層照樣報警。2. 權限模型拆解GitHub Packages 的三種身份與寫權限門檻2.1 GITHUB_TOKEN 與 PAT 的權限差異GitHub Actions 里有兩種常見的 token一類是自動生成的GITHUB_TOKEN。每個倉庫執(zhí)行 workflow 時都會臨時生成一個它的默認權限范圍取決于倉庫的配置。在較老或未調(diào)整過的倉庫里這個 token 默認是只讀的能 checkout 代碼能讀包的信息但推不了鏡像。要給它寫權限必須在 workflow 里顯式聲明。另一類是個人訪問令牌PATPersonal Access Token這是你自己在賬號設置里創(chuàng)建的。它不屬于某個倉庫而是屬于你這個賬號。你需要手動勾選訪問范圍比如write:packages、read:packages。把 PAT 放到倉庫的 Secrets 里在 workflow 里調(diào)用就能用它來登錄 ghcr.io。這兩者的關系可以簡單理解成GITHUB_TOKEN是“臨時工”權限由配置決定workflow 結束后自動失效PAT 是“長期員工”創(chuàng)建時定好權限只要不手動撤銷就一直有效。寫 workflow 時優(yōu)先用GITHUB_TOKEN更安全因為它只在需要的地方臨時生效。但如果 workflow 需要跨倉庫推送、或者目標鏡像不歸當前倉庫所有PAT 反而是更靈活的選擇。2.2 鏡像命名空間、倉庫歸屬與權限校驗規(guī)則權限報錯還和一個容易被忽略的細節(jié)有關鏡像名的 owner 部分。ghcr.io 的鏡像地址結構是這樣的ghcr.io/owner/image-name:tagowner可以是用戶名也可以是組織名。GitHub 在校驗權限時會檢查當前 token 是否對owner下的這個包有寫權限。舉個例子。假如你的倉庫是alice/my-projectgithub.repository_owner是alice鏡像名是ghcr.io/alice/my-image。這種情況下只要GITHUB_TOKEN有 packages 寫權限就能正常推送。但如果是 fork 場景就要小心了。fork 出來的倉庫里github.repository_owner會變成 fork 后的屬主如果 workflow 里把鏡像寫死了比如ghcr.io/alice/my-image但當前 token 實際對應bob的倉庫那么即使是alice倉庫里定義的公開包用bob的 token 去推alice的命名空間一樣會報write_package。還有一種情況是同一個倉庫內(nèi)如果之前用別的賬號或錯誤的命名空間創(chuàng)建過同名包GitHub Packages 會把這個包歸到那個命名空間下后續(xù)再用新 token 推就會遇到權限沖突。這類問題排查起來非常隱蔽因為你可能以為自己在推 A 包實際上 GitHub 把它識別成了另一個 B 包而當前 token 對 B 包確實沒有寫權限。2.3 workflow permissions 的兩種配置路徑解決寫權限核心就是讓GITHUB_TOKEN擁有packages: write。配置方式有兩種建議兩個地方都確認一遍。第一種是在 workflow 文件里顯式聲明??梢栽谖募攲釉O置也可以在 job 級別設置permissions: contents: read packages: write放在 job 級別會更精確不會把多余權限擴散到其他 job。比如jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write第二種是在倉庫設置里修改默認 Workflow permissions。路徑是倉庫 Settings - Actions - General - Workflow permissions把選項從 “Read repository contents and packages permissions” 改成 “Read and write permissions”。需要注意的是倉庫級設置只是默認值。如果 workflow 文件里顯式寫了permissions那以 workflow 文件為準。所以我習慣的做法是倉庫設置允許讀寫同時 workflow 文件里也顯式聲明雙保險。3. 完整修復方案從零到一推送 ghcr.io 鏡像3.1 方案 A用 GITHUB_TOKEN 的最小配置如果你只想讓當前倉庫的 workflow 把鏡像推到當前所有者的 ghcr.io 命名空間用GITHUB_TOKEN就夠了不需要額外創(chuàng)建任何密鑰。完整可用的 workflow 如下name: push-to-ghcr on: push: branches: [main] jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout code uses: actions/checkoutv4 - name: Set up Docker Buildx uses: docker/setup-buildx-actionv3 - name: Log in to ghcr.io uses: docker/login-actionv3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-actionv6 with: context: . push: true tags: | ghcr.io/${{ github.repository_owner }}/demo:latest ghcr.io/${{ github.repository_owner }}/demo:${{ github.sha }}這里有幾個值得留意的細節(jié)docker/login-action的username用github.actor也就是觸發(fā) workflow 的用戶名這是官方示例的常見寫法。重要的是password必須是secrets.GITHUB_TOKEN而不是任何明文密碼。docker/build-push-action的push: true表示構建完成后直接推送它會復用前面 login-action 生成的 Docker 配置不需要再單獨寫docker push。tags 里建議同時打latest和 commit SHA 的標記方便后續(xù)回溯。如果項目用的是docker build加docker push命令的方式也可以只要在 build 之前確認登錄成功即可- name: Build and push with docker CLI run: | docker build -t ghcr.io/${{ github.repository_owner }}/demo:v1 . docker push ghcr.io/${{ github.repository_owner }}/demo:v1這種方式對于單階段構建來說足夠不過多平臺構建或需要緩存時用 build-push-action 會更順手。3.2 方案 B使用 PAT 推送的完整步驟如果你的場景屬于下面幾種用 PAT 更合適鏡像名 owner 不是當前倉庫 owner比如要從用戶倉庫推到同一個組織下的另一個包需要跨倉庫復用同一個推送憑據(jù)workflow 里除了推 ghcr.io還需要調(diào)用其他需要更高權限的 API。創(chuàng)建 PAT 的路徑是GitHub 右上角頭像 - Settings - Developer settings - Personal access tokens - Tokens (classic)點擊 Generate new token在 scopes 里勾選write:packages注意勾選這個會自動帶上read:packagesdelete:packages如果需要刪除包按需勾選repo如果要推送的倉庫是 private且 workflow 需要訪問倉庫源碼則需要這個 scope如果你用的是 Fine-grained token權限設置會更細需要在 Account permissions 里找到 Packages設置成 Read and write。同時要選擇一個目標賬號或組織并授權對應的倉庫。生成 token 后把它添加到倉庫的 secrets 中。路徑是倉庫 Settings - Secrets and variables - Actions - New repository secretName 填GHCR_TOKENValue 粘貼剛才生成的 token。然后 workflow 里改成- name: Log in to ghcr.io uses: docker/login-actionv3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_TOKEN }}這里有個坑使用 PAT 時github.actor是觸發(fā) workflow 的用戶名而 PAT 是你自己創(chuàng)建的那個賬號的憑據(jù)。如果觸發(fā)用戶和 PAT 不是同一個賬號可能會導致登錄失敗因為 ghcr.io 會拿用戶名去匹配 token。更穩(wěn)妥的做法是把用戶名也寫死在 secrets 里或者直接用 PAT 所屬賬號的 username。比如你的賬號是deploy-bot可以用with: registry: ghcr.io username: deploy-bot password: ${{ secrets.GHCR_TOKEN }}這樣能減少很多“為什么 username 明明對但登錄失敗”的疑惑。3.3 組織倉庫與私有包的特殊處理如果你的倉庫屬于某個組織推 ghcr.io 時還有一處容易忽略的配置。組織管理員可能對 “Package creation” 做了限制。需要去組織設置里確認組織 Settings - Packages - Package creation確保允許 Actions 創(chuàng)建或更新容器鏡像否則即使 workflow 的 permissions 沒問題也會被組織層面的策略攔下來。另外第一次推送到 ghcr.io 后新創(chuàng)建的包默認是私有的。如果你是構建完鏡像其他環(huán)境要用需要手動調(diào)整包的可見性。調(diào)整路徑倉庫主頁 - Packages - 選擇對應鏡像 - Package settings - Danger Zone - Change visibility改成 public 后其他人或服務器才能免登錄拉取。如果保持 private那拉取端也需要先登錄 ghcr.io并具備對應包的讀權限。私有包的拉取配置常見做法是在服務器上維護一個只讀 token用 docker login 登錄后再 docker pull。具體權限只需要read:packages不需要寫權限這樣即使 token 泄露最壞情況也只是能拉取鏡像不能篡改。4. 常見問題與排查套路4.1 排查清單遇到write_package報錯我建議按這個順序逐項排查不要一上來就懷疑 token 泄露或者 workflow 寫錯確認 workflow 文件的permissions里是否包含packages: write。如果是在 job 級別設置的確認當前執(zhí)行 push 的 job 是哪一個。登錄用的 password 是secrets.GITHUB_TOKEN還是secrets.XXX。如果用 PAT確認 secret 名稱沒有拼寫錯誤。手動在本地跑一次 docker login 做驗證。比如用 PAT 登錄 ghcr.io然后嘗試 push 一個測試鏡像看是不是同樣報錯。這一步能幫你區(qū)分問題是出在 GitHub Actions 環(huán)境還是出在 token 本身。檢查鏡像名的 owner 是否和 token 的歸屬一致。用戶倉庫推到用戶命名空間沒問題但推到組織命名空間時token 必須擁有該組織的權限。查看目標包是否已經(jīng)存在且歸屬是否異常。如果包之前被推到別的 owner 下需要先把舊包刪除或調(diào)整權限。4.2 高頻報錯對照表為了讓你排查方便我整理了一張對照表列幾個最常見的情況報錯信息可能原因解決辦法denied: permission_denied: write_packagetoken 沒有 packages 寫權限在 workflow 中設置permissions: packages: write或改用有write:packages權限的 PATdenied: permission_denied: read_packagetoken 連讀權限都沒有檢查 token 是否至少勾選read:packagesunexpected status ... 403 Forbiddenpush 請求被拒絕通常是寫權限不足或命名空間不匹配檢查 owner 命名空間、token 權限、組織包設置denied: requested access to the resource is denied推送的 owner 不在 token 授權范圍內(nèi)如果使用 fine-grained token確認已授權目標賬號/組織login attempt to https://ghcr.io/v2/ failed with status: 401 Unauthorized登錄憑據(jù)錯誤或 token 無效檢查 secret 名稱、PAT 是否過期、用戶名是否匹配構建成功但推送后拉取時報 401包是 private 狀態(tài)登錄后拉取或?qū)梢娦愿臑?public4.3 經(jīng)驗與避坑最后分享幾個我在實際項目中積累的經(jīng)驗。第一個是不要把permissions寫在整個 workflow 頂層就完事。如果你同時存在多個 job頂層 permissions 會作用于所有 job這其實沒問題但有些團隊會有安全審計要求希望最小化權限。我建議把packages: write只加在真正需要推送的 job 上其他 job 保持只讀這樣即使某個 job 被惡意注入也沒法拿 token 去污染 ghcr.io。第二個是關于secrets.GITHUB_TOKEN和自定義 secret 的選擇。有人覺得 PAT 一勞永逸就一直用 PAT但 PAT 一旦泄露影響范圍是整個賬號名下的所有包。GITHUB_TOKEN的優(yōu)勢在于每個 workflow 都是獨立的臨時 token自動過期即使日志里被打印出來別人也無法復用。所以能用GITHUB_TOKEN就優(yōu)先用它除非遇到它確實覆蓋不了的場景。第三個是如果你使用了docker/build-push-action要注意它和docker/login-action之間的執(zhí)行順序。login-action 必須在 build-push-action 之前執(zhí)行因為 build-push-action 會讀取 docker 的認證配置。順序反了即使 login 成功push 時依然會提示未認證。第四個是如果報錯信息指向buildx不要慌。buildx 是 Docker 的構建插件它本身不負責權限校驗。最終 403 還是 ghcr.io 返回的只是錯誤信息被包了一層多讀了那一行就容易被誤導。真正要看的是報錯里有沒有permission_denied或denied這種關鍵詞。第五個是遇到write_package報錯時可以先檢查 GitHub Packages 頁面。有時候 ghcr.io 上已經(jīng)有一個同名包但是歸屬在另一個賬號下。你可以打開鏡像的 Package settings 頁面看看 Owner 是誰。如果 owner 和你預期的不一致最快的修復方式是把舊包刪掉再重新推送一次。第一次推送會重新在正確的命名空間下創(chuàng)建包。關于鏡像拉取慢或者鏡像源配置這類問題限于篇幅這里不展開。如果你已經(jīng)能成功推送 ghcr.io那說明整個 CI 鏈路已經(jīng)通了剩下的就是鏡像加速、緩存策略這些優(yōu)化層面的事按需調(diào)整就行。我在實際工作中遇到最多次的反而是最初級的問題workflow 文件里忘記寫permissions。GitHub 出于安全考慮在很多新建倉庫里默認把GITHUB_TOKEN設成了只讀。這個設計很合理但也確實讓不少初次接入 ghcr.io 的團隊卡在write_package上。只要記住一條任何操作 ghcr.io 的 job都必須顯式聲明packages: write然后像檢查密碼一樣去檢查你的登錄憑據(jù)來源這類問題基本都能在五分鐘內(nèi)定位。