iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

https://ithelp.ithome.com.tw/upload/images/20260820/20183337kvBeQKSpxz.png
https://ithelp.ithome.com.tw/upload/images/20260820/20183337FbFUaEQtI8.png
https://ithelp.ithome.com.tw/upload/images/20260820/20183337FUBOXfsgmJ.png
情境:阻擋團隊成員的可通過編譯,但不符合團隊規則的程式
圖片為失敗的示意
(修改了task順序,build太吃資源被放到其他檢查完成後做)

今日目的

今天做兩件事:

  1. npm-build —— 裝依賴、建出 dist/,順帶把 npm ci 從直連改成走 Nexus
  2. eslint-check —— 用自己的規則檢查自己的碼

ESLint 是什麼

ESLint 是 JavaScript / TypeScript 的靜態檢查工具(linter)。它讀原始碼、比對規則、把不符合的地方印出來 —— 不執行程式碼,也不改變 build 的產物

它管的是兩類事:

  • 可能出錯的寫法 —— 用了 any、宣告了沒用到的變數
  • 團隊約定 —— 誰可以 import 誰、模組邊界怎麼劃

核心規則之外,大部分規則來自外掛。這個 workspace 裝了兩組:typescript-eslint 管型別相關的,@nx/eslint-plugin 管專案邊界的 —— §8 那三條規則有兩條就是它們提供的。

規則報出來,不等於 CI 會停。 中間隔著一個 severity,那才是本篇的重點(§4 展開)。

順序上 ESLint 一定要等 npm-build,因為 eslint 本體與外掛都裝在 node_modules —— 沒跑過 npm ci 就沒有 eslint 可用。

快樂路徑

要做的事其實很短,五步:

# 做什麼 關鍵
1 修 lock 檔 npm install --package-lock-only,用與 CI 同版的 npm §0
2 npm-build Task 兩個 step:寫 .npmrc(有憑證)→ 裝依賴+build(沒憑證) §3
3 eslint-check Task 一個 step,一行指令 §4
4 接進 Pipeline npm-buildgit-cloneeslint-checknpm-build §5
5 push 一次 六個 Task 全綠,86 秒 §6

真正在做事的就這三行:

# npm-build / step 2 —— 關鍵是這個環境變數,讓 .npmrc 待在工作區外
npm_config_userconfig=/npmcfg/npmrc
npm ci --no-audit --no-fund && npx nx build web

# eslint-check —— 全部
npx nx run-many --target=lint --projects=web --parallel=1 -- --max-warnings=0

其餘篇幅是「為什麼」:§1–§2 選 image,§7 算每次 CI 多付的成本,§8–§12 解釋三條規則為什麼擋不下來。

這篇的技術說明只講一件事:這條 pipeline 的閘門是 error

只擋 error 是取捨,不這樣切,管道會被一堆「知道就好」的東西卡住跑不動。代價是三條看起來有防護的規則都擋不下 CI:兩條沒被歸到 error,一條歸到了卻沒有管轄對象。


實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
Node v22.23.2 / npm 10.9.8(node:22-alpine,musl)
Nx 23.1.1 / Angular 22
Nexus Repository 3.93.0-06(COMMUNITY)
用戶端:Windows 11 Pro + PowerShell 5.1

第一部分:把兩支 Task 做出來

0. 前置:修 lock 檔

症狀

package.jsonpackage-lock.json 不同步,npm ci 直接失敗:

npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: yaml@2.9.0 from lock file

只有這一項。 不是「第一項」—— npm ci 會把缺漏全部列出來,這裡就是只缺一個。

修法

npm install --package-lock-only --no-audit --no-fund

結果:

lock 檔 1,011,046 -> 1,010,347 bytes      ← 縮小 699 bytes
npm ci --no-audit --no-fund → exit 0
added 1727 packages in 3m

檔案變小是正常的。 --package-lock-only 不是「補上缺的那筆」,是依 package.json 重算整棵樹


變因是 npm 版本,不是作業系統

「不要在 Windows 上修 lock 檔」是常見說法。這次補測之後不成立

同一份壞掉的 lock 檔、同一個 package.json,只換重算環境:

重算環境 lock 檔 條目 巢狀 yaml@2.9.0 npm ci
原始(壞的) 1,011,046 1876 EUSAGE
Windows + npm 10.9.8 1,010,347 1877 exit 0
Windows + npm 11.13.0 1,011,114 1876 EUSAGE
alpine + npm 10.9.8 1,010,347 1877 exit 0

看第二列與第四列 —— byte 數完全相同。

只要 npm 同版,Windows 與 alpine 的產出一字不差:平台變體也一樣(win32 101 次、darwin 82 次,就是 §12 的那兩個數字),@nx/nx-linux-x64-musl@rspack/binding-linux-x64-musl 都在。

平台不是變因。

那什麼是變因

版本。這台主機的預設是 npm 11.13.0(跟著 node 24 裝的),跟 CI 容器的 10.9.8 不同版。

用 11.13.0 重算完再驗一次:

npm error Missing: yaml@2.9.0 from lock file

跟修之前一字不差 —— 等於沒修。 巢狀的 yaml@2.9.0 沒被加進去,條目停在 1876。

所以「在容器裡做」的理由不是躲開 Windows,是拿到與 CI 相同的 npm。
主機上裝的是哪個版本你控制不了;容器的 image tag 寫死在 Task 裡。

npm 10.9.8 會剝掉 81 筆條目的 libc 欄位libc: ["glibc"] / ["musl"]),npm 11 保留。

加上新的 yaml 條目之後,淨減 699。


帶回主機、推上去

把新的 lock 檔帶回主機推上去。

取回來的東西要有下界檢查 —— > 600,000 bytes、首字元是 {。理由見 Day 18 §13。

推上去會觸發一次 CI。gitleaks-scan 對一百萬 bytes 的 JSON 變更沒有意見:

1 commits scanned.
scanned ~1061807 bytes (1.06 MB) in 65.4ms
no leaks found

1. 共用前置

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kc = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$NEXUS_ROUTE = "nexus-nexus-proxy.apps-crc.testing"
$NEXUS_SVC   = "nexus-nexus3.nexus-proxy.svc.cluster.local:8081"

ci 已經有 Day 15 複製過去的 ci-npm-credentials,這篇直接用。


2. Node image:node:22-alpine

裡面長這樣:

node v22.23.2      npm 10.9.8
uid=0(root)
Alpine Linux       /bin/sh -> busybox
沒有 git、沒有 bash
libc = musl

為什麼不選 Debian 版

node:22-alpine node:22(Debian)
層數 / 大小 4 層 / 55.1 MB 8 層 / 389.5 MB
最大層 50.2 MB 201.9 MB

關鍵是最大層那一列,不是總大小。

Day 19 §11.2 那個吃到 504 的 blob 是 159.5 MB。Debian 版的最大層比它還大 —— 第一次拉很可能撞上同一個 Route timeout。

三件會直接影響 Task 怎麼寫

特性 後果
跑起來是 root 不需要 safe.directory(Day 18 §11.1 的兩根支柱一樣成立)
沒有 git 要用 git 指令得先 apk add git
/bin/shbusybox date -dPIPESTATUS、bash 專屬語法都不能用(Day 19 §11.3)

細節在 §12。


3. npm-build Task:兩個 step,憑證只給第一個

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: npm-build
  namespace: ci
spec:
  params:
    - name: NX_PROJECT
      type: string
      default: web
  workspaces:
    - name: source
  volumes:
    - name: npmcfg
      emptyDir: {}
  stepTemplate:
    volumeMounts:
      - name: npmcfg
        mountPath: /npmcfg
  steps:
    - name: write-npmrc
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/node:22-alpine
      env:
        - name: NPM_USER
          valueFrom:
            secretKeyRef: { name: ci-npm-credentials, key: username }
        - name: NPM_PASS
          valueFrom:
            secretKeyRef: { name: ci-npm-credentials, key: password }
        - name: NEXUS_SVC
          value: nexus-nexus3.nexus-proxy.svc.cluster.local:8081
      script: |
        #!/bin/sh
        set -e
        AUTH=$(printf '%s:%s' "$NPM_USER" "$NPM_PASS" | base64 -w0)
        cat > /npmcfg/npmrc <<EOF
        registry=http://${NEXUS_SVC}/repository/npm-group/
        always-auth=true
        //${NEXUS_SVC}/repository/npm-group/:_auth=${AUTH}
        EOF
        chmod 600 /npmcfg/npmrc
        echo "已產生 /npmcfg/npmrc:"
        sed 's/_auth=.*/_auth=<redacted>/' /npmcfg/npmrc

    - name: install-and-build
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/node:22-alpine
      workingDir: $(workspaces.source.path)
      env:
        - name: npm_config_userconfig
          value: /npmcfg/npmrc
      script: |
        #!/bin/sh
        set -e
        echo "=== registry ==="
        npm config get registry

        echo "=== npm ci ==="
        npm ci --no-audit --no-fund
        du -sh node_modules

        echo "=== nx build ==="
        npx nx build $(params.NX_PROJECT)
        du -sh dist
        ls -la dist/apps/$(params.NX_PROJECT)/browser

為什麼拆兩個 step

跟 Day 19 的 semgrep 同形:Tekton 的 env 是 per-step。

憑證只注入第一步,install-and-build 從頭到尾看不到 npm 密碼。

關鍵是 npm_config_userconfig

.npmrc 預設要跟 package.json 同層。那樣的話第二步一定碰得到憑證,隔離做不成

npm_config_userconfig.npmrc 可以放在工作區外。它是 npm 標準的環境變數形式(npm_config_<key>),實測在 10.9.8 上有效:

export npm_config_userconfig=/npmcfg/npmrc
npm config get registry → http://…/repository/npm-group/
npm ci                  → exit 0
工作區沒有 .npmrc(正確)

額外的好處:工作區不殘留 .npmrc,不會被後面的 Task 讀到,也不會被 git status 當成未追蹤檔案。

走 Nexus 快 4.8 倍

直連 registry.npmjs.org    206 秒
走 Nexus 第一次             43 秒
走 Nexus 第二次             29 秒

第一次就快,是因為快取早就熱了 —— Day 15 那次安裝已經把大部分套件灌進 npm-proxy。在一個全新的 Nexus 上第一次會慢得多。

lock 檔的 resolved 不用改。
1877 個條目裡有 resolved 的那 1876 筆全部指向 registry.npmjs.org,但 npm ci 會把主機部分換成當下設定的 registry、只保留路徑。
lock 檔保持與來源無關,換 registry 只是換設定。

建出來的東西

dist/apps/web/browser     index.html, main-*.js, styles-*.css, favicon.ico
dist/apps/web/server      server.mjs, main.server.mjs, assets-chunks/
dist 總計                  2.2 MB      build 耗時 18 秒

dist/apps/web/browser 跟 repo 裡現有 DockerfileCOPY 路徑一致,之後接 buildah 不用改。

注意:這個 app 有 SSRserver/ 與 prerendered route)。
現有的 Dockerfile 只服務 browser/,nginx 跑不了 server.mjs。要上 SSR 的話 Dockerfile 與 base image 都要換 —— 本篇不處理。


4. eslint-check Task

severity 決定它擋不擋

每條 ESLint 規則有三個等級:

等級 數字 報不報 exit code
off 0 不檢查 0
warn 1 0
error 2 1

CI 的閘門就是那個 exit code。errorexit 1 → Task 失敗 → Pipeline 停;只有 warningexit 0 → 一路綠燈。

所以「規則有沒有設」跟「規則會不會擋」是兩件事,中間隔著一個 severity。

只擋 error 是預設,也是刻意的取捨。 把每個建議都升成 error,管道會被技術債卡到跑不動;只擋 error,管道跑得動,代價是 warn 那一層形同沒有閘門。這條 pipeline 到目前為止都是這個政策——§6 的 semgrep 也是用 --severity=ERROR 把 200 多條規則篩到 24 條。

--max-warnings=<n> 是唯一的例外:warning 數超過 n 也會 exit 1。下面這支 Task 就是用它,替 ESLint 這一格把閘門往下挪一階。

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: eslint-check
  namespace: ci
spec:
  params:
    - name: NX_PROJECT
      type: string
      default: web
  workspaces:
    - name: source
  steps:
    - name: lint
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/node:22-alpine
      workingDir: $(workspaces.source.path)
      script: |
        #!/bin/sh
        set -e
        test -d node_modules || { echo "沒有 node_modules,npm-build 沒跑過"; exit 1; }

        npx nx run-many --target=lint \
          --projects="$(params.NX_PROJECT)" \
          --parallel=1 \
          -- --max-warnings=0

為什麼要帶 --max-warnings=0

這個 workspace 目前 0 個 warning,所以加上去不會先卡住一輪技術債。

實測它確實會擋,旗標也確實穿過 nx 傳到了 eslint —— 三組對照的證據放在 §9,這裡不重複。

沒有這個旗標的話,no-explicit-any 只會警告、不會擋。 它的 severity 是 [1](warn)。

這是「規則存在但不生效」的典型,§8–§11 會展開。


第二部分:接上去、跑一次、算成本

5. 加進 Pipeline

npm-build 直接接 git-clone,跟兩個掃描並行;eslint-check 接在 npm-build 之後。

[
  {"op":"add","path":"/spec/tasks/-","value":{
    "name":"npm-build",
    "runAfter":["git-clone"],
    "taskRef":{"name":"npm-build"},
    "workspaces":[{"name":"source","workspace":"shared-workspace"}]
  }},
  {"op":"add","path":"/spec/tasks/-","value":{
    "name":"eslint-check",
    "runAfter":["npm-build"],
    "taskRef":{"name":"eslint-check"},
    "workspaces":[{"name":"source","workspace":"shared-workspace"}]
  }}
]
& $OC --kubeconfig $kc patch pipeline.v1.tekton.dev d15-happy-path -n ci `
    --type=json --patch-file pipeline-patch.json

PowerShell 5.1 的坑:JSON patch 一定要走 --patch-file,不要用 -p '[...]'。內層雙引號會被吃掉。

確認:

git-clone <-
workspace-probe <- ["git-clone"]
gitleaks-scan <- ["git-clone"]
semgrep-scan <- ["git-clone"]
npm-build <- ["git-clone"]
eslint-check <- ["npm-build"]

並行讀寫同一顆 PVC 是安全的

接在 git-clone 之後的有四個:workspace-probe(Day 13 留下的)、gitleaks-scansemgrep-scannpm-build。其中三個會實際碰到工作區內容,而 pipeline-source-pvcRWO,靠 affinity assistant 排到同一節點。

要問的是:npm ci 寫 775 MB node_modules 的期間,掃描 Task 會不會被影響?

光看正式跑一次看不出來 —— gitleaks-scansemgrep-scan 十幾秒就結束,那時 node_modules 才剛開始長。

所以另外寫了一個刻意拉長的探測:semgrep 掃一次、sleep 60、再掃,重複四輪,跨越整個 npm ci 的過程。

node_modules=1MB     Scanning 21 files → 12 files   exit=0
node_modules=346MB   Scanning 21 files → 12 files   exit=0
node_modules=646MB   Scanning 21 files → 12 files   exit=0
node_modules=776MB   Scanning 21 files → 12 files   exit=0

四次完全相同。 --exclude=node_modules 擋得乾淨,node_modules 從 1 MB 長到 776 MB 對 semgrep 的掃描範圍沒有任何影響。

別被那個秒數騙了:那次探測的 Task 耗時 203 秒,是三次 sleep 60 加起來的,不是 semgrep 的耗時。正式 Pipeline 裡 semgrep-scan 只要 16 秒 —— 見 §6。


6. push 一個 commit,走完整條鏈路

git commit -am "docs: note Day 20 npm-build and eslint-check"
git push origin main
TASK              STATUS      START                  END
git-clone         Succeeded   2026-08-15T04:12:29Z   2026-08-15T04:12:38Z     9 秒
gitleaks-scan     Succeeded   2026-08-15T04:12:38Z   2026-08-15T04:12:45Z     7 秒
semgrep-scan      Succeeded   2026-08-15T04:12:38Z   2026-08-15T04:12:54Z    16 秒
workspace-probe   Succeeded   2026-08-15T04:12:38Z   2026-08-15T04:13:24Z    46 秒
npm-build         Succeeded   2026-08-15T04:12:38Z   2026-08-15T04:13:38Z    60 秒
eslint-check      Succeeded   2026-08-15T04:13:38Z   2026-08-15T04:13:54Z    16 秒

PipelineRun  True/Succeeded   Tasks Completed: 6 (Failed: 0, Cancelled 0), Skipped: 0
總耗時       04:12:28 → 04:13:54 = 86 秒

時間戳就是 DAG。 四個並行的 Task 都在 git-clone 結束的同一秒(04:12:38)起跑;eslint-checknpm-build 結束的同一秒(04:13:38)起跑。

npm-build / write-npmrc
  已產生 /npmcfg/npmrc:
  registry=http://nexus-nexus3.nexus-proxy.svc.cluster.local:8081/repository/npm-group/
  //…/repository/npm-group/:_auth=<redacted>

npm-build / install-and-build
  added 1727 packages in 33s
  775.3M  node_modules
  Output location: /workspace/source/dist/apps/web
  2.2M  dist
  -rw-r--r-- 1 root 1000860000  19623  index.html
  -rw-r--r-- 1 root 1000860000 257728  main-L44NG7NU.js

semgrep-scan / fetch-rules
  content-type  = text/x-yaml
  size          = 214313
  last-modified = Sat, 15 Aug 2026 02:34:20 GMT
  規則快照年齡 = 0 天

semgrep-scan / scan
  Ran 24 rules on 12 files: 0 findings.

eslint-check
  With additional flags:
    --max-warnings=0
  > nx run web:lint --max-warnings=0
  ✔ All files pass linting

從那份輸出讀得出四件事

觀察 意思
npm ci 只花 33 秒 探測時是 43 秒,快取又更熱了
semgrep 只跑 24 條規則 規則檔有 200 多條,--severity=ERROR 篩到 24 條。掃 12 個檔案,與 Day 19 §5 一致
content-typetext/x-yaml 不是 text/yaml semgrep.dev 回的是 text/yaml,存進 Nexus 再讀出來就變了
--max-warnings=0 確實傳到了 eslint With additional flags:> nx run web:lint --max-warnings=0 兩行

第三列是差點踩到的坑。 Day 19 的 fetch-rules 用的是 case "$CT" in *yaml*) 這種萬用比對,所以擋得住。
當初若寫成 = "text/yaml" 的等值比較,這一步每次都會誤擋。


7. 每次 CI 的固定成本:22 秒與六萬行 log

git-cloneDELETE_EXISTING 預設是 true,用 rm -rfv 清空工作區。上一次留下的 775 MB node_modules 要逐檔刪掉。

---> Phase: Deleting all contents of checkout-dir '/workspace/output/'...
removed '/workspace/output//node_modules/@algolia/abtesting/LICENSE'
removed '/workspace/output//node_modules/@algolia/abtesting/dist/src/abtestingV3Client.cjs'
...

要看的不只是秒數。 rm -rfv 逐檔印出,真正要看的 clone 結果被埋在六萬行後面。

同一支 Task,差別只在工作區有沒有 node_modules

工作區有 node_modules(775 MB)   git-clone 22 秒   log 60,662 行
工作區沒有                        git-clone  9 秒   log    185 行

接上 npm-build 之後,每一次 CI 都會落在第一列。

§6 為什麼量到 9 秒? 因為那是 npm-build 上線後的第一跑,工作區還沒有上一輪留下的 node_modules。從第二跑起就是 22 秒。


收工前檢查清單

# 1. npm-build 的憑證只在第一個 step
& $OC --kubeconfig $kc get task npm-build -n ci -o json | ConvertFrom-Json |
    ForEach-Object { $_.spec.steps } |
    Select-Object name, @{n='secret';e={$_.env.valueFrom.secretKeyRef.name}}
# 期望:write-npmrc 有 ci-npm-credentials;install-and-build 沒有

# 2. .npmrc 有被移到工作區外
& $OC --kubeconfig $kc get task npm-build -n ci -o jsonpath='{.spec.steps[1].env[*].name}'
# 期望看到 npm_config_userconfig

# 3. eslint 有帶 --max-warnings=0
& $OC --kubeconfig $kc get task eslint-check -n ci -o jsonpath='{.spec.steps[0].script}' |
    Select-String -Pattern "max-warnings"

# 4. DAG
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci `
    -o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"

# 5. 流量真的走 Nexus(不要看 blobstore 增量)

& $OC --kubeconfig $kc logs nexus-nexus3-0 -n nexus-proxy -c tail-request-log --tail=2000 |
    Select-String -Pattern "npm-group" | Select-Object -Last 5
# 期望看到 ci-npm 的 GET,user agent 是 npm/10.9.8

第 5 項的判準是請求 log,不是 blobstore 增量。
快取熱的時候增量很小(這次只有 +93.4 MB),看不出走沒走。


技術說明:為什麼三條規則都不擋

8. 閘門設在 error:三條規則各差在哪

閘門設在 error(§4),這個 workspace 有三處「規則設好了、看起來有防護」,沒有一條擋得下 CI。三條差的地方都不一樣:

規則 設定 差在哪 補起來要 詳見
@typescript-eslint/no-explicit-any severity warn 沒歸到 error,不影響 exit code 一個旗標--max-warnings=0 §9
@nx/enforce-module-boundaries severity error 歸到了,但範圍是空的 —— 只有一個專案,沒有邊界可跨 建第二個專案+收緊 tags §10
Angular build budget 超標 工具的 error 門檻沒設,只有 warning 門檻 project.json 的門檻 §11

分野不是「有沒有失效」,是補起來要花多少力氣。 第一條一個旗標就升級了(本篇就這樣做),另外兩條旗標救不了。

第二列最值得注意。 它設在 error,而且真的會擋 —— 只是這個 workspace 裡沒有它管得到的東西。

嚴格度看得見,作用範圍看不見。

這跟 Day 16 那三個回收機制是同一個形狀:機制都在、都正常運轉,各自因為一個不同的理由沒有發生作用。


9. no-explicit-any:severity 是 warn

npx eslint --print-config apps/web/src/main.ts
@typescript-eslint/no-explicit-any = [1]      ← 1 = warn
@nx/enforce-module-boundaries      = [2, {…}] ← 2 = error

實測放一個 any

14:26  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
✖ 1 problem (0 errors, 1 warning)
exit=0

規則存在、會報、不擋。

誰把它降成 warn 的,我沒有查證。
typescript-eslint 的 recommended 會啟用這條規則,但這台讀到的是 [1]。可能來自 Nx 或 Angular 的預設集,也可能是產生器寫的 —— 沒有確認就不寫成結論。

warn 的設計本意是暫時的

ESLint 官方在討論 severity 用法時的立場很明確:建議只在你正在處理該規則的報告期間暫時使用 warn;長期使用會讓開發者把它當成噪音而無視。

這比追究誰設的更重要。

在只擋 error 的閘門下,一條長期停在 warn 的規則,實際效果跟 off 差不多 —— 差別只在它會在 log 裡佔幾行。除非把閘門往下挪一階,那正是下一小節做的事。

三組對照:證明旗標真的傳到了

這個 workspace 目前 0 個 warning,所以加 --max-warnings=0 不會先卡住一輪技術債。

0 warning 的基準測不出旗標有沒有真的傳到 eslint,所以另外造了一個 warning:

# 做法 exit 輸出
C1 不加旗標 0 ✖ 1 problem (0 errors, 1 warning)
C2 nx run-many … -- --max-warnings=0 1 ✖ 1 problem (0 errors, 1 warning)
C3 npx eslint <file> --max-warnings=0 1 ESLint found too many warnings (maximum: 0).

C2 與 C3 是必要的。

只跑 C1 那種「基準測試」看不出旗標有沒有生效 —— exit=0 在「旗標沒傳到」和「真的沒有 warning」兩種情況下長得一模一樣

C3 那句 ESLint found too many warnings 是最直接的證據。C2 的輸出裡也看得到 > nx run web:lint --max-warnings=0 —— 旗標確實穿過 nx 傳到了 eslint。


10. enforce-module-boundaries:severity 是 error,但沒有邊界可跨

這條規則設在 error,直覺上它應該是三條裡最嚴的。實測兩次都沒觸發。

兩次實測

A. 相對路徑深入 import:

14:10  warning  'x' is defined but never used  @typescript-eslint/no-unused-vars
✖ 1 problem (0 errors, 1 warning)
exit=0

唯一的 warning 是 no-unused-vars不是 enforce-module-boundaries

這裡最容易誤判。
如果只看「有沒有報錯」,會以為規則生效了 —— 實際上報的是完全不同的規則。
要看 RuleID。

B. import 一個不存在的專案@frontend-nx-mono/nonexistent-lib):一樣沒觸發。

成因:這條規則管的是「跨專案」

Nx 官方對這條規則的描述是:它強制的是專案的 public API —— 每個專案在 index.ts 定義自己的公開介面,若另一個專案試圖從別的專案深處匯入變數,lint 時會報錯。

關鍵是「另一個專案」。

這個 workspace 的現況:

專案清單                apps/web           ← 只有一個
apps/web 的 tags        []                 ← 空陣列
depConstraints          [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }]
  • A 的相對路徑(apps/web/src/app/…apps/web/src/main.ts)是同一個專案內部,本來就不在管轄範圍
  • B 的 @frontend-nx-mono/nonexistent-lib 解析不成任何專案,沒有東西可比對

所以舊做法裡「相對路徑 import 會被擋」這個示範,在這個 repo 上做不出來 —— 不是規則寬鬆,是沒有邊界可跨。

tags: [] 也不是中性的

Nx 文件另有一句:「沒有任何 tag 的專案不能依賴任何其他專案。」

聽起來很嚴,但那要 depConstraints 真的在比對 tag 才成立。

這台是萬用字元 sourceTag: '*'* 匹配所有專案(包含沒有 tag 的),所以連這一層也被關掉了。

三層都被關掉了

現況 要補什麼
專案數 只有一個(範圍空) 建第二個專案
tags [] 給兩邊 tags
depConstraints 萬用字元 * 收緊

三者任何一層補上都不夠 —— 要三個都補。

值得帶走的:規則的嚴格度(severity)與它的作用範圍是兩件事。前者看得見,後者看不見。
--print-config 印出 [2](error)讓人以為它很嚴,但那個數字完全不含「它管得到什麼」的資訊。


11. Angular budget:第三種形態

nx build 的輸出裡有一則警告:

▲ [WARNING] angular:styles/component:scss;… exceeded maximum budget.
  Budget 4.00 kB was not met by 3.03 kB with a total of 7.03 kB.

不影響 exit code。 build 照樣成功。

Angular 的 budget 有 warningerror 兩個門檻。angular.json / project.json 裡沒設 error 的話,超標永遠只是警告

要把 build 當成關卡,得另外處理 —— 這篇不做,但要知道它跟前兩條一樣,屬於「設定看起來有防護」那一類。


12. alpine 的三個限制

node:22-alpine 選對了,但它的三個特性會直接影響 Task 怎麼寫。

一、沒有 git。 Nx 的 affected 偵測需要 git 歷史,要用的話得先 apk add --no-cache git。這篇的 Task 沒用到。

二、/bin/sh 是 busybox。 date -d 不吃 RFC-1123、沒有 PIPESTATUS、bash 專屬語法全部不能用。Day 19 §11.3 那個坑就是這樣來的。

三、執行身分是 root。 所以不需要 git config --global --add safe.directory —— Day 18 §11.1 的兩根支柱在這裡一樣成立(pipelines-sccRunAsAny + image 的 USER 是 root)。

換一個 non-root 的 Node image 就要加回來。

musl 沒出事的原因

npm ci 在 alpine 上裝的是 -musl 變體:

@nx/nx-linux-x64-musl-23.1.1.tgz              8,699,845 bytes
@rspack/binding-linux-x64-musl-2.1.8.tgz     25,675,405 bytes

而重算的 lock 檔保留了所有平台的 optional 變體 —— muslgnu 各三筆、darwin 82 次、win32 101 次。

npm 記錄全部變體,安裝時依 os / cpu / libc 選。所以 lock 檔在 alpine、Debian、macOS、Windows 之間通用(這也是 §0 那張表的另一面)。

一個例外值得記

@esbuild/linux-x64        存在
@esbuild/linux-x64-musl   上游根本沒出這個套件

esbuild 沒有分開的 musl 建置,它的 linux-x64 二進位在兩種 libc 上都能跑。

「原生模組 = 一定要 musl 版」不成立,要看各套件自己怎麼發布。


收尾

三件值得帶走的。

一、CI 的閘門是 severity,不是「設定檔裡有沒有這條規則」。

預設只擋 error,這是取捨:管道跑得動,代價是 warn 那一層沒有閘門。三條規則都通得過「設定檔裡有這條規則」的檢查,沒有一條擋得下 CI —— 但三條的差距不一樣。no-explicit-any 只是沒歸到 error一個 --max-warnings=0 就升級了,本篇也真的加了;enforce-module-boundaries 是範圍空的,Angular budget 是門檻沒設,這兩條旗標救不了。

要判斷一條規則會不會擋,看的是 severity 加上作用範圍,不是它在不在設定檔裡。

二、嚴格度看得見,作用範圍看不見。

--print-config 印出 [2] 讓人以為 enforce-module-boundaries 很嚴,但那個數字完全不含「它管得到什麼」的資訊。

要知道它有沒有在管,只能故意違反一次 —— 而且要看 RuleID,不是看「有沒有報錯」。那次實驗真的報了一個 warning,但報的是 no-unused-vars

三、證明「有沒有走 Nexus」不能看間接指標。

blobstore 只增加 93.4 MB,因為快取早就熱了 —— 那個數字既不能證明走了,也不能證明沒走。直接證據是 tail-request-log 裡 1739 筆 ci-npm 的請求。

間接指標在環境穩定之後會失去鑑別力,要看的是請求本身。



參考資料

ESLint / typescript-eslint / Nx

文件 用到的結論
ESLint — Configure Rules severity 的三種值與數字對應(0/1/2)
eslint/eslint discussion #16512 官方立場:warn 建議只在處理該規則報告的期間暫時使用,長期使用會讓開發者把它當噪音無視
typescript-eslint — no-explicit-any 這條規則由 recommended 啟用;any 是型別系統的逃生門
Nx — Enforce Module Boundaries 規則強制的是專案的 public API,管的是跨專案的 import;「沒有任何 tag 的專案不能依賴任何其他專案」
Nx — enforce-module-boundaries ESLint 規則 depConstraints 的結構;sourceTag / onlyDependOnLibsWithTags

npm

文件 用到的結論
npm config npm_config_<key> 環境變數形式;userconfig 的用途
npm ci 要求 package.json 與 lock 檔同步,否則 EUSAGE

上一篇
Day 19:靜態分析的規則從哪來 —— semgrep 與一份會過期的規則快照
下一篇
Day 21:第三道掃描 —— trivy fs 與五層過濾
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言