Skip to content
Published on

The Complete Guide to Package Managers — How npm, uv, RPM, and Homebrew Work and How to Publish Software

Authors

Introduction

Package managers are the backbone of software development infrastructure. Behind the everyday commands we use, like npm install, pip install, brew install, and yum install, lie complex mechanisms for dependency resolution, version management, and binary distribution.

In this post, we dissect the internals of four major package managers and walk through how to register your own software in each ecosystem.


Part 1: npm Internals (JavaScript / Node.js)

1-1. What Is npm?

npm (Node Package Manager) is the standard package manager for the JavaScript ecosystem. It has three core components.

Registry: The central repository where all packages are stored. Hosted at registry.npmjs.org and distributed worldwide via CDN. As of 2026, over 3 million packages are registered.

package.json: The file that declares project metadata and dependencies.

{
  "name": "my-project",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "~4.17.21"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}

node_modules: The directory where dependencies are actually installed. npm uses a nested structure by default but flattens it as much as possible through hoisting.

1-2. Dependency Resolution

npm's dependency resolution can be explained through four key concepts.

Semver (Semantic Versioning):

npm follows semver rules. Versions use the MAJOR.MINOR.PATCH format, and each range specifier has a specific meaning.

SpecifierMeaningExample
^4.18.0Fixed MAJOR, allow MINOR and PATCH4.18.0 up to but not 5.0.0
~4.17.21Fixed MAJOR and MINOR, allow PATCH only4.17.21 up to but not 4.18.0
4.18.0Exactly that version4.18.0 only
>=4.0.0That version and above4.0.0 and higher

Lock Files:

package-lock.json records the exact version, integrity hash, and resolved URL of every installed package. This ensures the entire team can reproduce identical dependency trees.

{
  "name": "my-project",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/express": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
      "integrity": "sha512-abc123..."
    }
  }
}

Hoisting:

If package A depends on lodash 4.17.21 and package B also depends on lodash 4.17.21, npm installs lodash only once in the top-level node_modules. However, if different versions are required, duplicates are installed in nested node_modules directories.

Phantom Dependencies:

Because of hoisting, you can import packages that your project does not directly depend on. A package may be usable simply because it was hoisted by another dependency, even though it is not declared in your package.json. This leads to breakages when the dependency tree changes.

1-3. npm vs yarn vs pnpm

Here is a comparison of the three main package managers.

Featurenpmyarn (Berry)pnpm
Storagenode_modules (hoisted)Plug'n'Play (PnP)content-addressable store + symlinks
Lock filepackage-lock.jsonyarn.lockpnpm-lock.yaml
Phantom dep preventionNoYes (strict PnP)Yes (isolated node_modules)
Disk usageHighLowVery low (hard links)
Workspacesnpm workspacesyarn workspacespnpm workspaces
PerformanceAverageGoodVery good

The Core Idea of pnpm:

pnpm stores packages once in a global content-addressable store and creates hard links in each project's node_modules. Even if 10 projects use the same version of lodash, it is stored only once on disk.

~/.pnpm-store/
  v3/
    files/
      ab/cdef1234...   # actual files of lodash 4.17.21

project-a/node_modules/.pnpm/
  lodash@4.17.21/
    node_modules/
      lodash/
        index.js  -->  ~/.pnpm-store/v3/files/ab/cdef1234... (hard link)

1-4. Publishing a Package to npm

Here is the step-by-step process for publishing your package to the npm registry.

Step 1: Create an Account and Log In

npm adduser
# or if you already have an account
npm login

Step 2: Initialize the Package

mkdir my-awesome-lib
cd my-awesome-lib
npm init

Step 3: Complete package.json

{
  "name": "my-awesome-lib",
  "version": "1.0.0",
  "description": "A library that does awesome things",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  },
  "keywords": ["awesome", "utility"],
  "author": "Your Name",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/my-awesome-lib"
  }
}

Step 4: Publish

# Verify the build
npm run build

# Preview before publishing (check which files are included)
npm pack --dry-run

# Actually publish
npm publish

# For scoped packages (public)
npm publish --access public

Step 5: Version Management

# Bump patch version (1.0.0 -> 1.0.1)
npm version patch

# Bump minor version (1.0.1 -> 1.1.0)
npm version minor

# Bump major version (1.1.0 -> 2.0.0)
npm version major

# Publish
npm publish

Part 2: uv Internals (Python)

2-1. What Is uv?

uv is an ultra-fast Python package manager and project management tool built in Rust by Astral. It replaces pip while delivering 10x to 100x faster performance.

The reasons uv is fast include:

  • Written in Rust: Compiled to a native binary with no Python interpreter overhead
  • Parallel downloads: Dependency resolution and downloads happen concurrently
  • Global cache: Packages downloaded once are reused across all projects
  • Optimized SAT solver: Efficiently resolves the dependency graph
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create a project
uv init my-project
cd my-project

# Add dependencies
uv add requests flask

# Sync dependencies (based on lock file)
uv sync

2-2. pip vs uv vs poetry vs conda

Featurepipuvpoetryconda
LanguagePythonRustPythonPython/C
Dependency resolutionBacktrackingSAT solverSAT solverSAT solver
Lock fileNone (manual freeze)uv.lockpoetry.lockenvironment.yml
Virtual env managementNo (separate venv)Yes (built-in)Yes (built-in)Yes (built-in)
Speed (cold install)Slow (baseline)10-100x faster2-5x fasterSlow
Build systemsetuptoolsSelf-resolvingSelf-buildingSelf-building
Non-Python packagesNoNoNoYes (numpy C libs, etc.)

Speed comparison (real benchmark):

# Installing requests + flask + sqlalchemy (cold cache)
pip install:    12.4s
poetry install:  8.1s
uv sync:         0.8s   # 15x faster

2-3. How uv Resolves Dependencies

uv uses a SAT solver based on the PubGrub algorithm. Here is the step-by-step process.

1. Build the Dependency Graph:

Starting from the project's direct dependencies, uv reads each package's metadata to construct the full transitive dependency graph.

2. Constraint Propagation:

Each package's version requirements are converted into constraints, which are propagated to narrow down the possible version space.

3. Unit Propagation:

When a variable has only one possible value remaining, that value is fixed and related constraints are updated.

4. Conflict-Driven Clause Learning (CDCL):

When a conflict occurs, uv analyzes its root cause and adds a "learned clause" to avoid repeating the same failure.

Example: A>=1.0 requires B>=2.0, but C<1.5 requires B<2.0
  -> Conflict detected
  -> Learned: A>=1.0 AND C<1.5 cannot hold simultaneously
  -> Backtrack and try different versions

2-4. Publishing a Package to PyPI

Here is the modern approach to registering a Python package on PyPI (Python Package Index).

Step 1: Write pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-python-lib"
version = "1.0.0"
description = "A useful Python library"
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
authors = [
    { name = "Your Name", email = "you@example.com" }
]
dependencies = [
    "requests>=2.28.0",
    "pydantic>=2.0",
]

[project.urls]
Homepage = "https://github.com/yourname/my-python-lib"
Documentation = "https://my-python-lib.readthedocs.io"

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]

Step 2: Build

# Install build tools
uv add --dev build

# Run the build
python -m build

# .whl and .tar.gz files are created in the dist/ directory
ls dist/
# my_python_lib-1.0.0-py3-none-any.whl
# my_python_lib-1.0.0.tar.gz

Step 3: Test on TestPyPI

# Install twine
uv add --dev twine

# Upload to TestPyPI
python -m twine upload --repository testpypi dist/*

# Test installation
pip install --index-url https://test.pypi.org/simple/ my-python-lib

Step 4: Publish to PyPI

# Upload to PyPI
python -m twine upload dist/*

# Now anyone can install it
pip install my-python-lib
# or
uv add my-python-lib

Trusted Publisher Setup (GitHub Actions):

name: Publish to PyPI
on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install build
      - run: python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1

This approach lets you publish to PyPI without API tokens, using GitHub OIDC tokens for authentication.


Part 3: RPM (Red Hat / CentOS / Rocky Linux)

3-1. What Is RPM?

RPM (Red Hat Package Manager) is the package management system for Red Hat-based Linux distributions. Its core components are as follows.

RPM Files: Binary package files with the .rpm extension. They contain compiled programs, configuration files, documentation, and install/uninstall scripts.

RPM Database: Located at /var/lib/rpm, it tracks information about all installed packages.

yum / dnf: Higher-level tools that solve RPM's dependency resolution problem. RPM can only install individual packages, but dnf automatically resolves dependencies and downloads packages from remote repositories.

# Direct RPM usage (no automatic dependency resolution)
rpm -ivh package-1.0.0-1.el9.x86_64.rpm

# Using dnf (automatic dependency resolution)
dnf install nginx

# Query package info
rpm -qi nginx

# List files in a package
rpm -ql nginx

Spec Files: Recipe files for building RPM packages. They define how to compile source code, which files to install where, and what dependencies are needed.

3-2. Building an RPM Package

Step 1: Prepare the Build Environment

# Install build tools
dnf install rpm-build rpmdevtools

# Create the build directory structure
rpmdev-setuptree

# The resulting structure:
# ~/rpmbuild/
#   BUILD/      - where builds are performed
#   RPMS/       - built RPM files
#   SOURCES/    - source tarballs
#   SPECS/      - spec files
#   SRPMS/      - source RPM files

Step 2: Write a Spec File

Name:           myapp
Version:        1.0.0
Release:        1%{?dist}
Summary:        My awesome application

License:        MIT
URL:            https://github.com/yourname/myapp
Source0:        %{name}-%{version}.tar.gz

BuildRequires:  gcc
BuildRequires:  make
Requires:       openssl-libs

%description
MyApp is an awesome application that does useful things.
It supports multiple platforms and is easy to configure.

%prep
%autosetup

%build
%configure
%make_build

%install
%make_install

%files
%license LICENSE
%doc README.md
%{_bindir}/myapp
%{_mandir}/man1/myapp.1*
%config(noreplace) %{_sysconfdir}/myapp.conf

%changelog
* Sat Apr 12 2026 Your Name <you@example.com> - 1.0.0-1
- Initial package

Step 3: Build

# Copy source tarball to SOURCES
cp myapp-1.0.0.tar.gz ~/rpmbuild/SOURCES/

# Build RPM (-ba: both binary and source RPM)
rpmbuild -ba ~/rpmbuild/SPECS/myapp.spec

# Check the built RPM
ls ~/rpmbuild/RPMS/x86_64/
# myapp-1.0.0-1.el9.x86_64.rpm

Step 4: Create a Local Repository

# Install createrepo
dnf install createrepo_c

# Create the repo directory
mkdir -p /var/www/html/myrepo/

# Copy RPMs
cp ~/rpmbuild/RPMS/x86_64/myapp-*.rpm /var/www/html/myrepo/

# Generate repo metadata
createrepo /var/www/html/myrepo/
# /etc/yum.repos.d/myrepo.repo
[myrepo]
name=My Custom Repository
baseurl=http://myserver.example.com/myrepo/
enabled=1
gpgcheck=0

3-3. DEB vs RPM Comparison

FeatureRPM (Red Hat family)DEB (Debian family)
DistributionsRHEL, CentOS, Rocky, FedoraDebian, Ubuntu, Mint
Package format.rpm.deb
Low-level toolrpmdpkg
High-level toolyum / dnfapt / apt-get
Package definitionspec filedebian/ directory (control, rules, etc.)
Build toolrpmbuilddpkg-buildpackage
Repo creationcreaterepoapt-ftparchive / reprepro
Script stagespre/post install/uninstallpreinst/postinst/prerm/postrm
SigningGPGGPG (apt-key)

The key difference lies in design philosophy. RPM's spec file puts everything in one file, while DEB's debian/ directory separates concerns into different files by role.


Part 4: Homebrew (macOS / Linux)

4-1. How Homebrew Works

Homebrew is the unofficial package manager for macOS (and Linux). Here are the core concepts.

Formula: A Ruby script that defines how to install a package. It includes the source URL, build options, dependencies, and installation steps.

Tap: A Git repository that contains a collection of Formulae. The default Tap is homebrew-core, and anyone can create their own Tap.

Cellar: The location where packages are actually installed. On macOS, this is /opt/homebrew/Cellar/ (Apple Silicon) or /usr/local/Cellar/ (Intel).

Keg-only: A package installed in the Cellar but not symlinked into PATH. This prevents conflicts when the system already has the same program. A classic example is openssl.

# Install a package
brew install wget

# Check the install path
brew --prefix wget
# /opt/homebrew/opt/wget

# Cellar internal structure
ls /opt/homebrew/Cellar/wget/1.21.4/
# bin/  etc/  share/

# Force-link a keg-only package
brew link --force openssl@3

Bottle: A pre-compiled binary package. Downloading a Bottle instead of building from source dramatically speeds up installation. Most official Formulae provide Bottles for both macOS and Linux.

4-2. Registering Software with Homebrew

There are three ways to register software with Homebrew.

Method 1: Create a Personal Tap

The simplest method with the fewest restrictions.

Step 1: Create a GitHub Repository

Create a repository named homebrew-mytap. Homebrew recognizes the homebrew- prefix as a Tap name.

Step 2: Write a Formula

# Formula/myapp.rb
class Myapp < Formula
  desc "My awesome command-line application"
  homepage "https://github.com/yourname/myapp"
  url "https://github.com/yourname/myapp/archive/refs/tags/v1.0.0.tar.gz"
  sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  license "MIT"

  depends_on "go" => :build

  def install
    system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}")
  end

  test do
    assert_match "myapp version #{version}", shell_output("#{bin}/myapp --version")
  end
end

Step 3: Use It

# Add the Tap
brew tap yourname/mytap

# Install
brew install yourname/mytap/myapp

# Or, after adding the Tap, install directly
brew install myapp

Method 2: Submit a PR to homebrew-core

Getting included in the official Homebrew requires meeting strict criteria.

Requirements:

  • At least 30 GitHub stars (or a substantial user base)
  • Stable release tags
  • An open-source license
  • Automated builds via CI/CD
  • Must build on both macOS and Linux
# Clone homebrew-core and add a Formula
brew tap --force homebrew/core
cd $(brew --repository homebrew/core)

# Formula creation helper
brew create https://github.com/yourname/myapp/archive/refs/tags/v1.0.0.tar.gz

# Validate the Formula
brew audit --new myapp
brew test myapp

# Submit PR (GitHub CLI)
gh pr create --title "myapp 1.0.0 (new formula)" --body "Description of the tool..."

Method 3: Cask (GUI Applications)

Used for distributing macOS GUI applications in .app, .dmg, or .pkg format.

# Casks/myguiapp.rb
cask "myguiapp" do
  version "2.1.0"
  sha256 "abc123def456..."

  url "https://github.com/yourname/myguiapp/releases/download/v#{version}/MyGuiApp-#{version}.dmg"
  name "MyGuiApp"
  desc "A beautiful GUI application"
  homepage "https://myguiapp.example.com"

  app "MyGuiApp.app"

  zap trash: [
    "~/Library/Application Support/MyGuiApp",
    "~/Library/Preferences/com.yourname.myguiapp.plist",
  ]
end
# Install a Cask
brew install --cask myguiapp

4-3. Formula Authoring in Detail

Homebrew Formulae are written in a Ruby DSL. Here are the key components.

class ComplexApp < Formula
  desc "A complex application with many build options"
  homepage "https://complexapp.dev"

  # Stable version source
  url "https://github.com/yourname/complexapp/archive/refs/tags/v2.0.0.tar.gz"
  sha256 "deadbeef..."

  # HEAD version (in development)
  head "https://github.com/yourname/complexapp.git", branch: "main"

  license "Apache-2.0"

  # Build dependencies
  depends_on "cmake" => :build
  depends_on "pkg-config" => :build

  # Runtime dependencies
  depends_on "openssl@3"
  depends_on "sqlite"

  # Platform restriction
  depends_on :macos

  def install
    args = %W[
      --prefix=#{prefix}
      --with-openssl=#{Formula["openssl@3"].opt_prefix}
      --with-sqlite=#{Formula["sqlite"].opt_prefix}
    ]

    system "./configure", *args
    system "make", "install"

    # Install shell completions
    bash_completion.install "completions/complexapp.bash"
    zsh_completion.install "completions/_complexapp"
    fish_completion.install "completions/complexapp.fish"
  end

  # Post-install message
  def caveats
    <<~EOS
      To start complexapp as a service:
        brew services start complexapp
    EOS
  end

  # Installation verification test
  test do
    assert_match version.to_s, shell_output("#{bin}/complexapp --version")
    system "#{bin}/complexapp", "check"
  end
end

Key Formula DSL Methods:

MethodPurposeExample
urlSource download URLurl "https://..."
sha256Integrity verification hashsha256 "abc..."
depends_onDependency declarationdepends_on "openssl@3"
installBuild and install stepssystem "make", "install"
testInstallation verificationassert_match ...
prefixBase install path/opt/homebrew/Cellar/app/1.0
binExecutable pathprefix/"bin"
etcConfig file pathprefix/"etc"
shareShared data pathprefix/"share"

Part 5: The Grand Comparison

5-1. Comprehensive Comparison Matrix

FeaturenpmPyPI (uv/pip)RPM (dnf)HomebrewAPT (deb)snapflatpak
TargetNode.js librariesPython librariesSystem packagesCLI/GUI appsSystem packagesDesktop appsDesktop apps
PlatformCross-platformCross-platformRHEL familymacOS/LinuxDebian familyLinuxLinux
Registrynpmjs.compypi.orgVendor reposhomebrew-coreVendor repossnapcraft.ioflathub.org
Isolationnode_modulesvirtualenvNone (system-wide)Cellar + symlinksNone (system-wide)SandboxSandbox
Dep resolutionsemver rangesSAT solverlibsolv (SAT)Built-inAPT solverSelf-managedRuntime sharing
Auto-updateNoNodnf-automaticbrew upgradeunattended-upgradessnapd (auto)No
Security signingnpm signaturesGPG/SigstoreGPGCode signing (Cask)GPGSnap Store signingFlathub signing
Size concernNoneNoneNoneNoneNoneLarge (bundled)Large (runtime)

5-2. Which Package Manager Should You Use?

To distribute a JavaScript/TypeScript library: Use npm or GitHub Packages.

To distribute a Python library: Register on PyPI and let users install with uv or pip.

To distribute system-level packages for Linux servers: Build RPM packages (for RHEL family) or DEB packages (for Debian family).

To distribute a CLI tool for macOS: Write a Homebrew Formula and publish it in a personal Tap or submit a PR to homebrew-core.

To distribute a cross-platform desktop application: Consider snap or flatpak. snap has built-in auto-updates, while flatpak offers a more open ecosystem.


Conclusion

Package managers are far more than simple installation tools. They solve the NP-complete problem of dependency resolution in practical ways, safely distribute millions of packages, and serve as the circulatory system of the developer ecosystem.

Understanding the internals of each package manager helps you resolve dependency conflicts faster, manage caches more efficiently, and makes the process of distributing your own software much smoother.

Try registering your project on npm, PyPI, Homebrew, or RPM. The best way to experience how a package manager works is to build and publish a package yourself.


References