Notes
← All notes
·frontend·13 min

daily-soup-widget 從寫到上架(下):tsc、npm 2FA、dist/、cron 四個 publish 踩雷

上篇講三個設計選型,這篇講把它真的送上 npm 的時候撞到的四個雷——TypeScript 不肯吐 .d.ts、npm 的 2FA 把 CI 擋在外面、dist/ 沒清漏 ship 了過期檔、GitHub Actions 的 cron 該設多頻繁。每一個都是「上線後才發現」的那種錯。

#widget#side-projects#npm#typescript#build-tooling#github-actions

上篇講三個設計選型——雙通道 distribution、Shadow DOM、build-time schedule。設計選完之後,就是把它真的送上架——push 到 GitHub、publish 到 npm、設好每天的排程。

這四個雷的共通點是「光看 README 寫不出來」:

  1. TypeScript 編不出 .d.ts 給 npm 用——noEmit: truedeclaration: true 兩個設定打架。
  2. npm publish 在我本機需要二次驗證(2FA),但 CI 上沒人按手機——要嘛把 token 升級成 granular access token,要嘛改用 GitHub Actions OIDC。
  3. 從 0.1.x 升到 0.2.0 時 dist/ 沒清,舊版的檔案被一起 publish 上去,npm 上的 package 比 git tag 還舊。
  4. 「每天的排程要每天跑一次 cron 嗎」——直覺答是,正確答是「看 schedule 多久過期一次」。

文章寫給沒 publish 過 npm 套件、沒 ship 過 CI cron 的人看。

本文導覽

TypeScript 編不出 .d.ts

.d.ts 是 TypeScript 的型別宣告檔,npm 套件用它告訴下載者「我這個函式吃什麼參數、回傳什麼」。沒有 .d.ts,TypeScript 使用者下載你的套件就會看到一片紅底「could not find a declaration file for module」1

$ npm publish
# 上去之後別人裝來:
$ npm install daily-soup-widget
$ import { DailySoup } from 'daily-soup-widget'
# error TS7016: Could not find a declaration file for module 'daily-soup-widget'.

第一次 publish 完就吃這個錯。回去翻 tsconfig.jsondeclaration: true 明明開了,為什麼沒生出 .d.ts

兩個設定打架

整個專案的 tsconfig.json 是給 Next.js 開發用的,裡面有一行:

{
  "compilerOptions": {
    "noEmit": true
  }
}

noEmit: true 的意思是「tsc 只做型別檢查,不要產出任何檔案」——Next.js 自己會把 TypeScript 編譯掉,不需要 tsc 再產一份。

問題是 noEmit: truedeclaration: true2tsc 看到 noEmit: true 就完全不寫檔案——連 .d.ts 也不寫。所以「tsc --noEmit 做型別檢查」跟「tsc.d.ts 給 npm 用」這兩件事,沒辦法用同一份 tsconfig.json 同時做。

解法:另開一份 build tsconfig

開一份 tsconfig.build.json 專門給「產 .d.ts」用:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "emitDeclarationOnly": true,
    "declaration": true,
    "outDir": "./dist/types",
    "incremental": false,
    "jsx": "react-jsx"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

四個關鍵設定:

  • noEmit: false 把母設定的 noEmit: true 翻過來。
  • emitDeclarationOnly: true 告訴 tsc 只產 .d.ts,不產 .js.js 我用 esbuild 另外打包)。
  • outDir: "./dist/types".d.ts 都集中放在一個資料夾,package.json"types" 欄位直接指過去。
  • include: ["src/**/*"] 限縮範圍,不要把 app/scripts/tests/ 也一起編成 .d.ts——只有 src/ 才是 npm 套件要對外的程式碼。

package.json 加一個 build:types script:

{
  "scripts": {
    "build:types": "tsc -p tsconfig.build.json"
  }
}

再執行 npm run build:typesdist/types/index.d.ts 就出來了。

為什麼不用一份 tsconfig 解決

最直覺的反應是「那把 noEmit: true 拿掉不就好了?」拿掉之後 npx tsc --noEmit 還是可以照常做型別檢查(指令參數會覆蓋設定檔),但 Next.js 在某些版本會跟「tsc 預設會 emit」的設定打架,VS Code 也會偶爾跳出莫名其妙的 ghost 檔案3

把「型別檢查」跟「產對外宣告檔」拆成兩份設定,是 monorepo 跟「同一個 repo 既是 app 又發 npm」的標準做法。

npm publish 撞 2FA 牆

.d.ts 解決之後,下一步是 npm publish。在本機跑:

$ npm publish --access public
npm notice
npm notice 📦  daily-soup-widget@0.1.0
npm notice === Tarball Contents ===
...
npm ERR! code EOTP
npm ERR! This operation requires a one-time password from your authenticator.

EOTP 是 "one-time password required"——npm 要我從 Authenticator app 輸入 6 位數驗證碼。本機輸完就過了。

問題是接下來想把這件事搬到 CI 上自動跑(push 一個 git tag → GitHub Actions 自動 publish)。CI 上沒人按手機,怎麼辦?

兩條路:granular token 或 GitHub OIDC

第一條路是 granular access token4。npm 在 2024 年推出「細粒度個人存取權杖」——你可以發一個專屬於某個 package 的 token,設定它「跳過 2FA」、「只能 publish 不能讀私人資源」、「30 天後過期」。流程:

  1. npm 網站 → Access Tokens → Generate New Token (Granular)
  2. 勾「Packages and scopes:daily-soup-widget」
  3. 勾「Permissions:Read and write」
  4. 勾「Bypass 2FA for publish」
  5. 把 token 存到 GitHub Secrets,叫 NPM_TOKEN

GitHub Actions 用:

- uses: actions/setup-node@v4
  with:
    node-version: '22'
    registry-url: 'https://registry.npmjs.org'
- run: npm publish --access public
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

registry-url 那行是必要的——setup-node 看到它才會把 NODE_AUTH_TOKEN 寫進 .npmrc

第二條路是 OIDC(Trusted Publishing)。npm 在 2024 年底加入 GitHub Actions OIDC 支援,CI 完全不需要存 long-lived token——每次 publish 都是 GitHub 即時發一個 short-lived token 給 npm。安全度高一截,但需要在 npm 網站把 package 設成「trust this GitHub workflow」。

我這次選 granular token,因為設定步驟少,後面要換 OIDC 不用改 publish script。token 過期前的提醒比較不可靠,記得在 calendar 上設一個。

「token 設了還是要 2FA」的隱藏雷

設好 token 之後 CI 第一次跑還是噴 EOTP。原因:npm 帳號層級有一個「Require 2FA for write actions」設定——這個比 token 設定優先。要在 npm 帳號 Settings → Two-Factor Authentication → 改成「Only for login」,granular token 的「Bypass 2FA」才有效。

這個關係寫在 npm 的 2FA 設定頁,不在 publish 教學頁裡——Authorization and writesAuthorization only 兩個 2FA 等級對應的就是「token 能不能 bypass」5

dist/ 沒清,0.2.0 漏 ship

0.1.5 publish 完之後改了一輪內容(從 60 句縮成 30 句),bump 版本到 0.2.0:

$ npm version 0.2.0
$ git push --tags
# CI 跑完,npm 上出現 0.2.0
$ npm install daily-soup-widget@0.2.0
# 拿下來解開……
$ cat dist/schedule-zh.json | jq '. | length'
60

60。我明明 build 的時候是 30 句,怎麼裝下來變回 60 句?

dist/ 沒清,build 是 merge

dist/ 是 build 輸出資料夾。我的 build:lib script 長這樣:

{
  "scripts": {
    "build:schedule": "tsx scripts/build-schedule.ts",
    "build:bundle": "tsx scripts/build-bundle.ts",
    "build:types": "tsc -p tsconfig.build.json",
    "build:lib": "npm run build:schedule && npm run build:bundle && npm run build:types"
  }
}

三個 build step 都會寫 dist/,但沒有任何一個 step 會先把 dist/ 清乾淨。所以:

  • 0.1.5 build 時產 dist/schedule-zh.json 60 句。
  • 0.2.0 build 時 build-schedule.ts 用新內容覆蓋 dist/schedule-zh.json 30 句——這檔對。
  • 但 0.1.5 還有別的舊檔案(例如某個被砍掉的英文版 dist/schedule-en.json)留在那裡。
  • npm publish 看到 dist/ 整包都 publish 上去。

我這次的具體狀況是 build-schedule.ts 改動之後輸出檔名變了(dist/schedule.jsondist/schedule-zh.json),舊的 dist/schedule.json 還在 dist/ 裡,被 publish 上去當作有效檔。

解法:每次 build 之前 rm -rf dist

加一個 clean script,串在 build:lib 最前面:

{
  "scripts": {
    "clean": "rm -rf dist",
    "build:lib": "npm run clean && npm run build:schedule && npm run build:bundle && npm run build:types"
  }
}

再加一個 prepublishOnly hook——它會在 npm publish 真的開始打包之前自動執行:

{
  "scripts": {
    "prepublishOnly": "npm run build:lib"
  }
}

prepublishOnlyprepublish 不一樣:prepublishnpm install 時也會跑(行為很怪),prepublishOnly 只在真的要 publish 時跑6。所以這個 hook 等於是「保證每次 publish 上 npm 的 dist/ 都是當下重新 build 的」。

bump 到 0.2.1 之後重新 publish,30 句出現了。

為什麼不靠 files 欄位限制

package.jsonfiles 欄位可以限定「只有這些路徑會被 publish」:

{
  "files": [
    "dist/embed.*",
    "dist/schedule-*.json",
    "dist/types/**/*.d.ts"
  ]
}

我有設這個,但它擋不住「dist/schedule.jsondist/schedule-*.json 都符合 glob」這種情況——schedule.json 自己就 match schedule-*.json 的 pattern。files 是 allowlist,不是「強迫每次重 build」。

對策就是雙保險:prepublishOnly 保證 dist/ 是新的,files 保證沒有 source map / .tsbuildinfo 之類的東西漏 ship。

GitHub Actions cron:每天跑還是每月跑

build-schedule.ts 會把「未來 90 天每天該顯示哪句話」算好,存進 dist/schedule-zh.json。第 91 天怎麼辦?需要 CI 定期重新跑一次,往後再延 90 天。

GitHub Actions 的 cron 設定長這樣7

on:
  schedule:
    - cron: '0 16 1 * *'  # 每月 1 號 UTC 16:00(= 台北時間 2 號 00:00)
  workflow_dispatch:

workflow_dispatch 加上去是為了手動觸發測試——上線前一定要先按一次手動跑,確認 CI 環境真的能跑通,不要等月初 cron 才發現 secret 沒設好。

直覺答 vs 實際答

最直覺的反應是「每天跑一次」——畢竟我寫的是 daily widget。

但**「daily widget」跟「daily regenerate」是兩件事**。schedule 一次算 90 天,每天跑代表「每天重算未來 90 天」——90 天的內容根本沒變化,只是右邊多一天、左邊少一天。如果 cron 改成每月跑一次,每月重算未來 90 天,讀者最久也是看 60 天前算的內容,沒有任何顯示落差。

換句話說,cron 頻率該對齊資源週轉週期,不是對齊「使用者體感名稱」:

看起來該對齊的實際該對齊的結論
「daily widget」這個名字schedule 涵蓋天數schedule 涵蓋 90 天 → 月 cron 夠用
「使用者每天看到的」schedule 是否過期不會過期(90 天 buffer)
內容是否更新內容 push 觸發 publish改 quote 用 PR、不用 cron

每天跑的成本不只是 GitHub Actions 額度——每跑一次 cron 都會 commit 一個「regen schedule」commit 進 git log,連續 30 次就 30 個雜訊 commit。

我的 cron 最後設成 0 16 1 * *——每月 1 號跑。

還有一個雷:concurrency group

cron 工作要加 concurrency

concurrency:
  group: regen-schedule
  cancel-in-progress: false

cancel-in-progress: false 的意思是「如果上一輪還在跑就排隊等,不要殺掉」。schedule 重新 build 中途被殺,會留下 dist/schedule-zh.json 寫到一半的狀態。cancel-in-progress: true 適合 PR build(新 push 就殺舊的),不適合 cron。

七個決策完整版

把上下兩篇的七個決策列在一起:

#決策選什麼為什麼
1Distribution channelscript tag + npm 兩條都走涵蓋兩種使用者,bundle / dependency 各自的優勢分到對的人
2樣式隔離Shadow DOMiframe 撐不出 RWD、scoped CSS 防不住宿主全域選擇器
3內容排程build 時算 90 天,存 JSON跳過 runtime API、跳過 CORS、過期日期還是不變
4.d.ts 產生另開 tsconfig.build.json母 tsconfig 的 noEmit: truedeclaration: true
5npm publish 認證granular token + bypass 2FAOIDC 設定多,granular token 對 side project 夠用
6dist/ 髒問題clean script + prepublishOnlyfiles allowlist 擋不住舊檔名 match 新 glob
7cron 頻率每月 1 號schedule 涵蓋 90 天,月 cron 留 60 天 buffer 已經夠

這七個決策的共通點是——指令本身很短,但每個指令的「為什麼這樣設」要靠撞才會懂。tsc -p tsconfig.build.json 是一行,但要先理解 noEmit: truedeclaration: true 哪個比較大;npm publish --access public 是一行,但要先設好 token、設好帳號 2FA 等級;cron: '0 16 1 * *' 是一行,但要先想清楚 schedule 涵蓋多久。

寫下來的目的是給「第二次做」用——第二次做的時候,看自己第一次踩雷的紀錄會省半天時間。

參考資料

Footnotes

  1. TypeScript Handbook — Declaration Files Introduction. https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html

  2. TypeScript TSConfig Reference — noEmit. https://www.typescriptlang.org/tsconfig/#noEmit(明確說明 noEmit: true 會阻止任何 emit,包含 declaration)

  3. TypeScript TSConfig Reference — emitDeclarationOnly. https://www.typescriptlang.org/tsconfig/#emitDeclarationOnly

  4. npm Docs — About access tokens (Granular access tokens). https://docs.npmjs.com/about-access-tokens

  5. npm Docs — Configuring two-factor authentication. https://docs.npmjs.com/configuring-two-factor-authentication("Authorization and writes" vs "Authorization only" 兩個 2FA 等級的差別)

  6. npm Docs — Scripts (prepublishOnly vs prepublish lifecycle). https://docs.npmjs.com/cli/v10/using-npm/scripts#prepare-and-prepublish

  7. GitHub Docs — Events that trigger workflows (schedule event, POSIX cron syntax). https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule