ข้ามไปยังเนื้อหา

การเผยแพร่ Package

การเผยแพร่ package ทำให้มันพร้อมใช้งานบน npm registry เพื่อให้ใครก็ตาม (หรือสมาชิกในองค์กรของคุณ) ติดตั้งได้ด้วย npm install กระบวนการนี้มีสามขั้นตอน: เตรียม package.json ด้วย metadata ที่ถูกต้อง, bump เวอร์ชัน, และรัน npm publish แต่ละขั้นตอนมี guardrail ที่ช่วยให้คุณ ship release ที่สะอาดและคาดเดาได้

package ที่พร้อม publish มักมีฟิลด์เหล่านี้ นอกเหนือจาก name และ version:

{
"name": "@myorg/my-util-lib",
"version": "1.0.0",
"description": "A small utility library",
"license": "MIT",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"bin": {
"my-util": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./helpers": {
"types": "./dist/helpers.d.ts",
"import": "./dist/helpers.mjs",
"require": "./dist/helpers.cjs"
}
},
"engines": {
"node": ">=18.0.0"
}
}
  • license — SPDX identifier ที่ผู้ใช้และ registry scanner ตรวจก่อนจะ depend on package ของคุณ
  • main / module — entry point แบบ CJS เดิม และ convention เฉพาะ bundler ที่ tool เก่าบางตัวยังมองหา ทั้งคู่ถูกแทนที่ด้วย exports แต่ยังเก็บไว้เพื่อ compat กับ tooling
  • types — ไฟล์ TypeScript declaration หลัก ใช้โดย editor และ tsc เมื่อ package ไม่มี exports map หรือใช้เป็น fallback
  • files — allowlist ของ path ที่รวมไว้ใน published tarball ทุกอย่างนอกเหนือจากนี้ (source file, test fixture, config) ถูก exclude ถ้าไม่ระบุ npm ยังใช้ชุด exclusion เริ่มต้น (node_modules, .git, และไฟล์ที่ตรงกับ .npmignore หรือถ้าไม่มีก็ .gitignore)
  • bin — map ชื่อคำสั่ง CLI ไปยัง entry-point script npm สร้าง symlink ใน .bin/ ทำให้คำสั่งพร้อมใช้งานใน PATH เมื่อติดตั้ง package และผ่าน npx
  • exports — ประกาศ public API surface path ใดที่ไม่ได้ระบุไว้ที่นี่จะเข้าถึงไม่ได้จากภายนอก ภายใน conditions object แต่ละอัน "types" ต้องมาก่อนเสมอ — ดู Module Resolution ว่าทำไมลำดับ key ถึงสำคัญ
{
"files": ["dist", "README.md", "LICENSE"]
}

"files" เป็น allowlist ไม่ใช่ denylist: มีเพียง path ที่ระบุไว้ที่นี่ (บวกกับไฟล์บางอย่างที่ npm รวมให้เสมอ เช่น package.json และ README) เท่านั้นที่จะอยู่ใน tarball วิธีนี้ทำให้ source map, test fixture, และ config เฉพาะเครื่องไม่หลุดไปกับสิ่งที่ผู้ใช้ดาวน์โหลด

Demo ด้านล่าง reimplement logic ของ allowlist นั้นตรงๆ ด้วย fs กับ fake package tree ที่มีไฟล์ .env และโฟลเดอร์ test/source ที่ไม่เคยอยู่ใน "files" เลย เพื่อให้กลไกการกรองเป็นรูปธรรม แทนที่จะแค่กล่าวอ้าง:

Node.js
Output (verified on Node 24)
Would ship in the tarball:
  README.md
  dist/index.js
  dist/nested/helper.js
  package.json
Excluded (not in "files"):
  .env
  src/index.ts
  test/index.test.js

.env, source TypeScript, และไฟล์ test ไม่ไปแตะเครื่องของผู้ใช้เลย npm pack --dry-run จริงบน layout เดียวกันเป๊ะนี้ยืนยันว่าไฟล์สามอย่างเดียวกันนี้ ship ออกไป — demo นี้ทำให้ กลไก ชัดเจนแทนที่จะขอให้คุณเชื่อ CLI output เฉยๆ

{
"bin": {
"my-util": "./dist/cli.js"
}
}

เมื่อ package ถูกติดตั้ง (แบบ local หรือ global) npm จะสร้าง executable ชื่อ my-util ใน node_modules/.bin/ (หรือ global bin directory) ที่ชี้ไปยัง ./dist/cli.js นี่คือสิ่งที่ทำให้คำสั่งนี้ใช้งานได้ผ่าน npx my-util และผ่าน script ใน npm run ของโปรเจกต์ที่ใช้งาน โดยไม่ต้องติดตั้งแบบ global

ทั้งสามอย่างจบลงที่การ “รันโค้ดบางอย่าง” ซึ่งเป็นเหตุผลว่าทำไม snippet ที่ AI generate ถึงสลับใช้กันโดยไม่สังเกตว่าพวกมันไม่เท่ากัน:

คำสั่งรันอะไรFallback ไปติดตั้งจาก registry ไหม?รัน lifecycle script pre/post ไหม?
npx <bin-name>bin entry — local (node_modules/.bin) ถ้ามี ไม่งั้นติดตั้งชั่วคราวจาก registryใช่ (prompt เว้นแต่มี --yes/-y)N/A — ไม่ใช่ script ใน package.json
npm exec -- <bin-name>bin lookup แบบเดียวกับ npx (npm 7+ implement npx บนฐาน exec)ใช่ พฤติกรรมเดียวกับ npxN/A — ไม่ใช่ script ใน package.json
node --run <script-name>script ที่ตั้งชื่อไว้ในฟิลด์ "scripts" ของ package.json โดยตรง — ไม่ spawn shellไม่ — มันรันแค่สิ่งที่อยู่ใน "scripts" แล้วไม่ — นี่คือความต่างที่ชัดที่สุดจาก npm run

Transcript จริง ทั้งหมด local และ offline (bin entry ชื่อ say-hi กับ script greet ที่นิยามไว้ใน package เดียวกัน):

Terminal window
npx say-hi
hi from local bin
Terminal window
npm exec -- say-hi
hi from local bin
Terminal window
node --run greet
hi from local bin

ทั้งสามพิมพ์เหมือนกันตรงนี้ — แต่เพิ่ม lifecycle script pregreet เข้าไปแล้วความต่างจะเห็นชัด:

Terminal window
npm run greet
> echo PRE-HOOK-RAN
PRE-HOOK-RAN
> node cli.js
hi from local bin
Terminal window
node --run greet
hi from local bin

node --run ข้าม pregreet ไปเลย — มันรันแค่ script ที่ระบุชื่อ ไม่มีการ resolve lifecycle hook และไม่มี shell คั่นกลาง ซึ่งเป็นเหตุผลที่มันเริ่มเร็วกว่า npm run ด้วย มันเป็นตัวแทนของ npm run <script> ไม่ใช่ของ npx/npm exec: มันติดตั้งอะไรไม่ได้เลย และรัน binary ที่ไม่ใช่ script ใน package.json อยู่แล้วไม่ได้

npm ใช้ semantic versioning (semver): MAJOR.MINOR.PATCH

  • PATCH — bug fix ที่ backwards-compatible
  • MINOR — feature ใหม่ที่ backwards-compatible
  • MAJOR — breaking change

ใช้ npm version เพื่อ bump และสร้าง tag ในขั้นตอนเดียว:

Terminal window
# Patch bump: 1.0.0 → 1.0.1 (bug fixes)
npm version patch
# Minor bump: 1.0.1 → 1.1.0 (backwards-compatible new features)
npm version minor
# Major bump: 1.1.0 → 2.0.0 (breaking changes)
npm version major

แต่ละคำสั่งอัปเดตฟิลด์ "version" ใน package.json, commit การเปลี่ยนแปลง, และสร้าง signed git tag (เช่น v1.0.1) push tag ด้วย git push --follow-tags

npm pack รวม package ของคุณเป็น tarball .tgz เหมือนกับที่ npm จะทำถ้าคุณรัน npm publish — แต่มันอยู่แค่ local เท่านั้น วิธีนี้ให้คุณตรวจสอบ artifact สุดท้ายก่อน ship จริง --dry-run ไปไกลกว่านั้นอีกขั้น: มันแสดงรายการเนื้อหา tarball โดยไม่เขียนอะไรลง disk เลย และไม่ต้อง login registry เลยด้วย

Terminal window
# Create a local tarball (e.g. myorg-my-util-lib-1.0.0.tgz)
npm pack
# Inspect its contents without extracting
tar -tzf myorg-my-util-lib-1.0.0.tgz
# Install it locally in another project to smoke-test
npm install ../my-util-lib/myorg-my-util-lib-1.0.0.tgz

ผลลัพธ์จริงของ npm pack --dry-run บน package ที่มี "files": ["dist", "README.md"], ไฟล์ .env, และไฟล์ secret ที่ไม่เกี่ยวข้องอยู่ข้างๆ กัน:

Terminal window
npm pack --dry-run
npm notice
npm notice 📦 @demo/[email protected]
npm notice Tarball Contents
npm notice 14B README.md
npm notice 21B dist/index.js
npm notice 139B package.json
npm notice Tarball Details
npm notice name: @demo/my-util-lib
npm notice version: 1.0.0
npm notice filename: demo-my-util-lib-1.0.0.tgz
npm notice package size: 284 B
npm notice unpacked size: 174 B
npm notice shasum: 59bc21d933556de397e4fa5056ea5a999750a8d3
npm notice integrity: sha512-FBadVz2MkDnJl[...]jO8KfPJW6WRuw==
npm notice total files: 3

.env และไฟล์ secret หายไปจากรายการเลย — เป็นการกรองแบบเดียวกันเป๊ะกับที่ demo แบบ fs ด้านบน reimplement

คุณต้อง authenticate (npm login) ก่อน publish npm อ่าน package.json เพื่อกำหนด registry ปลายทาง (ค่าเริ่มต้นคือ https://registry.npmjs.org)

Terminal window
# Log in to the registry
npm login
# Publish a public (unscoped) package
npm publish
# Publish a scoped package as public (scoped packages default to restricted)
npm publish --access public
# Publish under a dist-tag other than "latest" (e.g. a beta)
npm publish --tag beta
flowchart TD
  A["npm version patch/minor/major"] --> B["prepublishOnly script runs (build, test, lint)"]
  B --> C["npm pack --dry-run — inspect the tarball contents"]
  C --> D{"Looks right?"}
  D -->|"no"| E["Fix files/exports/scripts, repeat from npm pack"]
  E --> C
  D -->|"yes"| F["npm publish"]
  F --> G{"2FA required for this account/package?"}
  G -->|"yes"| H["Approve via authenticator or security key"]
  G -->|"no"| I["Tarball uploaded to the registry"]
  H --> I
  I --> J["Provenance attestation attached (if --provenance)"]
จากการ bump เวอร์ชันไปจนถึง tarball ที่ publish แล้ว

Version ที่ publish แล้วยกเลิกไม่ได้หลังผ่านไป 72 ชั่วโมง (หน้าต่างป้องกันการละเมิดของ npm) ดังนั้น guardrail ต้องอยู่ก่อน npm publish รัน ไม่ใช่หลังจากนั้น:

  • prepublishOnly — script ใน package.json ที่ npm รันอัตโนมัติก่อน pack เลย และรันเฉพาะก่อน npm publish จริง (ต่างจาก prepublish ที่ในอดีตยังรันตอน npm install ธรรมดาด้วย) ใส่ build และ test step ไว้ตรงนี้เพื่อไม่ให้ build ที่พังหลุดไป ship ได้:
    {
    "scripts": {
    "prepublishOnly": "npm test && npm run build"
    }
    }
  • npm pack --dry-run — การตรวจฟรี local ไม่ต้อง login จากหัวข้อก่อนหน้า รันมันแล้วอ่านรายการไฟล์จริงๆ ก่อน publish ทุกครั้ง
  • Two-factor authentication (2FA) — npm profile enable-2fa auth-and-writes ต้องการ one-time code (หรือ hardware security key) ทั้งตอน login และตอน publish ดังนั้น npm token ที่หลุดไปอย่างเดียวไม่พอที่จะ publish version อันตรายภายใต้ชื่อคุณ
  • Provenance (npm publish --provenance) — จาก CI environment ที่รองรับ (GitHub Actions, GitLab CI) มันแนบ commit และ build workflow ที่แน่นอนที่สร้าง tarball นั้นแบบ cryptographic เข้าไปด้วย มองเห็นได้บนหน้า npm registry สาธารณะ มันตอบคำถาม “สิ่งนี้ถูก build จาก source repo สาธารณะจริงไหม” สำหรับใครก็ตามที่ตรวจสอบ supply-chain incident

scoped package ใช้รูปแบบ @scope/name และมักใช้สำหรับ namespace ขององค์กร หรือจัดกลุ่ม package ที่เกี่ยวข้องกัน:

{
"name": "@myorg/my-util-lib",
"version": "1.0.0"
}

Scoped packages เป็น private โดยค่าเริ่มต้น บน npm registry เพื่อ publish แบบ public ต้องใส่ --access public เสมอ:

Terminal window
# Scaffold a scoped package
npm init --scope=@myorg
# Publish it publicly
npm publish --access public

Private scoped packages ต้องใช้ npm account แบบเสียเงิน หรือ private registry

ทุกอย่างในบทนี้มีอยู่เพราะ npm publish ใกล้เคียงกับ door ทางเดียวมากกว่าปุ่ม save byte ที่แน่นอนของ tarball ที่ publish แล้วคือถาวร — คุณแก้หรือแทนที่ 1.0.0 แบบเงียบๆ ทีหลังไม่ได้ ดังนั้น guardrail ทุกตัวในบทนี้ ("files", prepublishOnly, npm pack --dry-run, 2FA, provenance) มีอยู่เพื่อจับความผิดพลาดในไม่กี่วินาทีก่อนที่ door นั้นจะปิด เพราะไม่มีอะไรจับมันได้หลังจากนั้น

ตัวเลือกประโยชน์ต้นทุน
prepublishOnly รัน test/buildbuild ที่พัง publish ไม่ได้ — check รันอัตโนมัติ ไม่ต้องมีใครจำเพิ่มเวลาจริงให้ทุก publish; test ที่ flaky อาจ block release ที่ถูกต้อง
2FA ตอน publishnpm token ที่ถูกขโมยอย่างเดียว publish version อันตรายไม่ได้ทุก publish ต้องมี second factor พร้อมใช้ — เพิ่ม friction ให้ automated release pipeline เว้นแต่ตั้ง granular token ไว้
--provenanceลิงก์สาธารณะที่ verify ได้ระหว่าง tarball ที่ publish กับ source commit/workflow ที่แน่นอนที่ build มันใช้ได้เฉพาะจาก CI provider ที่รองรับ — เพิ่มย้อนหลังให้ version ที่ publish ไปแล้วไม่ได้
"files" allowlist ที่เข้มงวดTarball เล็กและคาดเดาได้ ไม่มีอะไรหลุดไปโดยไม่ตั้งใจลืมเพิ่มไฟล์ที่จำเป็นจริงๆ (wasm binary, font) ทำให้ package พังสำหรับผู้ใช้จนกว่าจะมี version ถัดไป
  • “ฉัน publish patch แก้ความผิดพลาดที่ ship ไปได้” คุณ publish version ใหม่ ได้ แต่ version ที่พังยังอยู่บน registry ตลอดไป (หน้าต่าง unpublish ของ npm คือ 72 ชั่วโมงและมีข้อจำกัดแม้ในช่วงนั้น) — ใครก็ตามที่ resolve version นั้นไปแล้วยังคงเก็บความผิดพลาดนั้นไว้
  • “npm pack กับ npm publish อาจ package ไฟล์ต่างกัน” ทั้งคู่ใช้ logic "files"/.npmignore เดียวกันเป๊ะ — npm pack --dry-run ไม่ใช่การประมาณ มันคือขั้นตอน build-tarball จริงๆ ลบแค่การอัปโหลดออก
  • “2FA ป้องกันแค่ login npm account ของฉัน” enable-2fa auth-and-writes ต้องการ second factor เพิ่มเติมสำหรับการ publish เองด้วย — auth token ที่ถูกขโมยหรือรั่วไหลอย่างเดียวไม่พอที่จะ push version อันตราย

Publish script ของ CI/CD ที่ AI generate มักละ prepublishOnly ไปเลย แล้วไปตรงจากการ bump version สู่ npm publish เพราะตัวอย่างในรูปแบบ tutorial ที่ training data ส่วนใหญ่มาจากนั้นสาธิตแค่ “วิธี publish” ไม่ใช่ “วิธี publish อย่างปลอดภัย”

🐛 หา bug ในโค้ดนี้

{
"name": "my-cli-tool",
"version": "1.0.0",
"bin": { "my-cli": "./dist/cli.js" },
"devDependencies": {
"commander": "^12.0.0"
}
}
เฉลย

commander คือ library parse argument ที่ CLI เรียกใช้จริงตอน runtime — ทุกครั้งที่ my-cli รัน บนเครื่องของผู้ใช้ทุกคน — มันจึงควรอยู่ใน "dependencies" ไม่ใช่ "devDependencies" เพราะ npm ข้าม devDependencies ตอน production install (npm install --omit=dev หรือเมื่อผู้ใช้ install นี้เป็น dependency จริงแทนที่จะ clone repo) ผู้ใช้ที่ install CLI นี้ตามปกติจะเจอ Cannot find module 'commander' ทันทีที่รัน วิธีแก้คือย้ายมัน: "dependencies": { "commander": "^12.0.0" }

ฟิลด์ "files" ใน package.json ควบคุมอะไร?
คุณรัน npm version minor กับ package เวอร์ชัน 2.3.1 เวอร์ชันใหม่คือเท่าใด?
ทำไม scoped packages ถึงต้องใช้ npm publish --access public เพื่อให้สาธารณชนติดตั้งได้?
ผู้ใช้ install CLI นี้เป็น dependency ปกติ (semantics ของ npm install --omit=dev ใช้ได้) จะเกิดอะไรขึ้นเมื่อพวกเขารัน my-cli?
{
  "name": "my-cli-tool",
  "bin": { "my-cli": "./dist/cli.js" },
  "devDependencies": {
    "commander": "^12.0.0"
  }
}
Publish pipeline ใกล้ Dockerfile ตัวหนึ่งรัน npm version patch แล้วตามด้วย npm publish ทันที ข้าม npm pack --dry-run กับ prepublishOnly ไปเลย สิ่งนี้เสียการป้องกันจริงอะไรไป?