[TOOLS] 12 分鐘閱讀OraCore 編輯部

FAQ 把 golangci-lint 變 CI 政策

把 golangci-lint FAQ 拆成可直接套用的 Go CI 政策,重點是版本對齊、先編譯再 lint、只擋新問題。

分享 LinkedIn
FAQ 把 golangci-lint 變 CI 政策

這篇把 golangci-lint 的 FAQ 轉成一份可直接抄進 CI 的政策。

我用 golangci-lint 一陣子了,工具本身其實不難用,難的是你以為它會照你的直覺跑。CI 一接上去,輸出一大坨,我就開始懷疑人生:這到底是 lint 壞掉、編譯壞掉,還是我把它丟進一個根本不能 build 的專案?這種摩擦最煩,因為它不會大爆炸,它只會慢慢把團隊對 gate 的信任磨掉。

後來我不是去看宣傳頁,我是去翻 FAQ。結果我才發現,真正有用的不是功能清單,是那些很不討喜但很誠實的限制:Go 版本怎麼對、typecheck 為什麼不能跳過、第一次跑為什麼慢、老專案怎麼不要被舊債淹死。FAQ 讀完,我才意識到它其實是一份政策草稿,只是沒幫你整理好。

我這篇就是把那份草稿整理成你可以直接拿去用的版本。原始來源是 golangci-lint FAQ,我不打算把它講得很玄,因為它本來就不是玄學;它只是把 CI 的邊界講清楚而已。

先別幻想它能跟上每個 Go 新版

訂閱 AI 趨勢週報

每週精選模型發布、工具應用與深度分析,直送信箱。不定期,不騷擾。

不會寄垃圾信,隨時可取消。

“Golangci-lint supports Go versions lower or equal to the Go version used to compile it.”

FAQ 把 golangci-lint 變 CI 政策

翻譯一下就是:你的 lint binary 不是神,它是拿某個 Go toolchain 編出來的,所以它能支援的 Go 版本有上限。FAQ 還補了一刀,說它的支援策略跟 Go 官方的兩個最新 minor 版本政策一致。也就是說,你如果先把 repo 升到最新 Go,卻沒同步更新 golangci-lint,CI 出怪事不是意外,是你自己在製造版本落差。

我看過很多團隊很愛「Go 先升,工具慢慢補」。聽起來很務實,實際上常常是把問題留給 CI。第一個失敗一出來,大家就開始罵 lint 不穩。通常不是不穩,是太舊。工具早就把訊號丟給你了,只是我們不想承認版本組合已經過期。

實操寫法很簡單:Go 和 golangci-lint 都要在 CI 裡明確 pin 版本,而且要一起升,不要各自漂。local 開發機跟 build image 也別差太遠,不然你會得到一堆「我本機沒事」的垃圾對話。如果你自己打包 golangci-lint,連它是用哪個 Go 編出來的都要寫進 release notes,不然排錯時大家只會互相甩鍋。

  • CI 裡固定 Go 版本,不要靠 latest。
  • golangci-lint 版本也固定,不要讓它自己飄。
  • 每次升 Go,都把 lint binary 一起驗證。

這件事我會建議你直接當成政策,不要當成最佳實務。因為一旦你把它寫成政策,團隊就比較不會在「先升再說」這種鬼故事上浪費時間。

typecheck 不是 lint,別硬把編譯錯誤包裝成提醒

“typecheck is not a linter, it doesn’t perform any analysis, it’s just a way to identify, parse, and display compiling errors.”

這句是整份 FAQ 最重要的一句。翻譯一下就是:typecheck 出錯,不代表某個 lint 規則抓到問題,而是你的程式根本還沒到能被分析的狀態。它只是把編譯錯誤顯示出來,沒有任何「我先幫你放水」的空間。

FAQ 也講得很直接:只要有 typecheck errors,golangci-lint 就沒辦法產生其他報告。很多人第一次看到只出一個錯,就以為別的 linters 沒跑。不是沒跑,是前面的門都沒過,後面根本沒東西可分析。這很煩,但很合理。

我自己最常遇到的是只 lint 部分 package 或某個檔案,結果 typecheck 爆掉。這時候大家第一反應常是「那就忽略它啊」。我懂這個衝動,但這個想法很危險。你一旦把編譯錯誤當成可忽略項,CI 就會開始對不能分析的 code 說「看起來沒事」,那比 noisy output 還糟。

實操寫法:先把編譯當成第一關。先跑 go build ./...,再期待 lint 的輸出有意義。若有 CGO,就先把系統 library 裝好;若是 private dependency,就先把 GOPROXYGOSUMDB 設對。你如果只查局部範圍,也要誠實面對那個範圍到底能不能獨立編譯。

  • 先修 build error,再修 lint error。
  • 能全模組檢查就別只查片段。
  • typecheck 不是 warning,是前置條件沒過。

這種分層很重要。你把編譯和 lint 混在一起,最後只會得到一個看似很嚴格、其實很會騙人的 gate。

別想跳過 typecheck,因為那是在跳過入口

“Why is it not possible to skip/ignore typecheck errors?”

FAQ 把 golangci-lint 變 CI 政策

FAQ 的回答其實就是上一段的延伸:因為 typecheck errors 會擋住分析,所以它不能像一般 lint finding 那樣被忽略。這不是工具小氣,是流程順序本來就這樣。你不能先把門踹開,再要求裡面的房間照常整理。

這裡最常見的誤解是把「忽略規則」拿來處理所有問題。那套邏輯只適用於你已經拿到一個有效結果,然後想排除某些已知例外。typecheck 完全不是這種情況。它是在說:這個程式目前不夠完整,後面的分析沒有可靠基礎。

我之前跟團隊吵過這件事,對方很想要一個「先讓 broken package 過去」的開關。我不是不能理解,畢竟大家都想先交付。但你如果這樣做,CI 會開始報告「乾淨」,而那個乾淨其實是假的。這種假乾淨最毒,因為它會讓人以為風險不存在。

實操寫法:如果你真的需要緩衝,請把 pipeline 切成兩段。第一段只負責證明 code 能編譯;第二段才跑 golangci-lint。若某個區域暫時壞掉,就用明確的 path 或 package 排除,並寫清楚原因。不要把 typecheck 當成一般 lint finding 來處理,這樣只會把問題藏起來。

這也是我對 FAQ 最服氣的地方:它沒有假裝能幫你解決所有壞狀況,它只是在提醒你,別拿錯工具處理錯層級的問題。

舊問題太多時,別硬推全量修復

“The idea is to not fix all existing issues. Fix only newly added issue: issues in new code.”

這句其實就是整篇的核心。翻譯一下:如果 repo 裡早就一堆 lint 債,你現在最該做的不是開一場全員修地獄,而是先阻止新的債繼續長大。這才是大多數團隊真的做得到的事。

FAQ 建議用 --new-from-merge-base=main--new-from-rev=HEAD~1。這個方向我完全同意,因為它把 lint 從「一次清完」變成「只管新東西」。我在大 repo 裡推 lint,最後能活下來的幾乎都是這種模式。不是因為它完美,是因為它不會逼大家先停工半年。

實操寫法:CI 針對 target branch 的 merge base 來比對,不要只看最後一個 commit。這樣比較能反映真正的變更範圍。如果你是簡單的 commit-based 檢查,就用 --new-from-rev=HEAD~1。重點不是你選哪個參數,而是你要明確定義「新 code」是什麼,不然 lint 會變成一個很會吵的歷史清算機。

  • branch CI 用 --new-from-merge-base=main
  • 單 commit 檢查用 --new-from-rev=HEAD~1
  • 舊債留在 backlog,不要卡住交付。

這種做法比較像政策,不像工具設定。差別很大,因為政策是在講團隊怎麼做決定,不是在講某個 flag 長什麼樣。

diff-based 過濾很方便,但它不是語意理解

“If an issue is not reported as the same line as the changes then the issue will be skipped.”

這句很關鍵,因為它直接告訴你:--new-from-* 是靠 git diff 和 issue 對齊在運作,不是靠什麼神奇的語意分析。也就是說,如果問題沒有落在變更行上,它就可能被跳過。這不是 bug,這是這套方法的代價。

我遇過 refactor 把檔案動了一輪,結果 warning 出現在 diff 之外的區塊,CI 就安靜得像沒事。那種安靜最可怕,因為它不是沒問題,而是你剛好沒看到。人很容易把 automation 的沉默誤認成正確。

實操寫法:先用 diff-based new-code lint,夠用就好;但如果你在做大幅重構、搬移 code、或一個 file 裡的問題很容易落在 changed line 之外,就改用 --whole-files。FAQ 也有提醒這點:whole-file 會看整個包含變更的檔案,不只看變更行。訊號會變廣,噪音也會變多,但總比漏掉真問題好。

我的原則很土:你要的是能抓住你在乎的錯,不是最漂亮的參數組合。只要 diff filter 開始遮住真問題,我就會把範圍放大。別讓工具太聰明,聰明到把你想抓的錯順手放過。

第一次跑很慢,不代表它天生拖累 CI

“Because the first run caches type information. All subsequent runs will be faster.”

這句的意思很單純:第一次跑慢,是因為它在做快取。翻譯一下就是,第一輪付的是初始化成本,後面才吃得到 cache 的好處。很多人看到第一次慢,就直接判它死刑,這其實有點冤。

我看過有人拿 cold start 來評估 golangci-lint,跑一次就說太慢、太重、太不值得。這種測法很常見,也很不公平。你如果只看第一次,等於拿還沒熱機的車去嫌它油耗差。CI 環境如果每次都重來,那就更需要把 cache 策略想清楚,而不是先怪工具。

實操寫法:本機開發時盡量保留 cache,CI 也盡量持久化。不要把 --fast-only 當成萬靈丹,然後再抱怨第一次跑很慢。FAQ 很明白地說了,第一次跑會先把 type info cache 起來,這本來就是成本。你要評估的是 warm cache 後的日常表現,不是冷啟動那一下。

如果你的 lint 流程是先 lint 再 build,我會建議你調順序。先 build,讓狀態穩定,再 lint。這樣 cache 才真的有機會幫你省時間,不然每次都在重新認識同一份 code,當然慢。

客製 lint 可以做,但別把主流程弄成雜物抽屜

FAQ 裡有提到 custom linter 的整合方式:你可以照手冊自己接,也可以開 GitHub issue 等維護者有空處理。這聽起來很客氣,但意思其實很明白:能接,不代表應該把主流程全拿去賭一個特殊需求。

翻譯一下就是,golangci-lint 的確可以擴充,但擴充是有成本的。如果你們團隊一直想加一些很私人的規則,我會先問:這個規則真的該放在 lint 層嗎?有些東西適合放在 analyzer,有些其實用獨立 script 更清楚。不要什麼都往同一個工具塞,最後沒人知道是哪條規則在擋 build。

我最怕看到 lint config 變成雜物抽屜。今天塞一個 one-off check,明天再塞一個團隊特例,久了之後整份設定像是誰都碰過、誰都不敢刪。FAQ 這裡沒有過度鼓吹客製,我反而覺得這很實在。

實操寫法:只有在規則有明確 owner、輸出格式穩定、而且真的會長期用,才把 custom linter 放進主 gate。還在試驗期的東西,先放外面。先讓它證明自己值得被納入,不要一開始就把主流程搞得像實驗室。

可抄的模板

# golangci-lint CI policy for Go repos
# Source: https://golangci-lint.run/docs/welcome/faq/
# Related docs: https://golangci-lint.run/ , https://golangci-lint.run/docs/usage/linters/

policy:
  versioning:
    go: "pin in CI"
    golangci-lint: "pin in CI"
    rule: "upgrade Go and golangci-lint together"
    note: "golangci-lint only supports Go versions <= the Go version used to compile it"

  build_gate:
    order:
      - "go mod tidy"
      - "go build ./..."
      - "golangci-lint run ./..."
    rule: "typecheck errors are build failures, not lint warnings"

  legacy_debt:
    rule: "do not block delivery on pre-existing issues in main"
    enforce_only_on_new_code: true
    commands:
      branch_ci: "golangci-lint run --new-from-merge-base=main"
      commit_ci: "golangci-lint run --new-from-rev=HEAD~1"

  diff_filtering:
    default: "diff-based new-code lint"
    widen_scope_when:
      - "refactor hides issues outside changed lines"
      - "file-level edits miss relevant warnings"
    fallback_command: "golangci-lint run --new-from-merge-base=main --whole-files"

  performance:
    expectation: "first run is slower because type info is cached"
    practice:
      - "persist cache locally and in CI when possible"
      - "judge speed after warm cache, not cold start"

  custom_checks:
    rule: "keep one-off checks out of the main gate until they have a clear owner and stable purpose"
    preferred_path: "use built-in linters first; add custom linters only when the use case is durable"

ci_statement: |
  The build must compile cleanly.
  golangci-lint is enforced only on new code.
  Legacy issues stay visible but do not block delivery.
  Typecheck errors are treated as build failures, not lint warnings.

這份模板我會直接丟進 repo,然後把 CI command 接好。它不花俏,但它把 FAQ 的重點都收進去了:版本要對齊、先編譯再 lint、只擋新 code、diff filter 不夠時要能放寬、custom check 別亂塞。這才是能長期活下來的寫法。

如果要我今天重做一次,我不會再把 golangci-lint 當成一個「跑一下看結果」的工具。我會把它當政策執行器。工具只是工具,真正決定它有沒有價值的,是你有沒有把邊界講清楚。

來源是官方 FAQ:https://golangci-lint.run/docs/welcome/faq/。我這篇的拆解、流程整理和模板是我自己的整理,原始觀點與命令範例則來自該頁面。