第二十三章. 私有制品库构建:GitHub Packages 与 GHCR 全流程实战

第二十三章. 私有制品库构建:GitHub Packages 与 GHCR 全流程实战

摘要:本章将带领你搭建企业级的私有制品供应链。我们将摒弃枯燥的理论,直接动手构建一个 Spring Boot 后端服务和一个 Vite 前端组件库。在实战过程中,我们将一步步打通 GitHub Packages 的 鉴权体系,配置自动化发布流水线,并亲手解决“跨项目权限”这一工程难题。

本章学习路径

  1. 资产准备:在 GitHub 后台申请“万能钥匙”(PAT),为本地发布做好铺垫。
  2. Docker 供应链:从零创建一个 Spring Boot 项目,编写 Dockerfile 并注入元数据,建立 GitHub Actions 自动化构建流。
  3. NPM 供应链:初始化一个 Vite TypeScript 项目,改造 package.json 适配私有源,实现组件库的云端发布。

23.1. 鉴权体系基石:握有“门禁卡”

在开始写任何代码之前,我们需要先解决一个根本问题:GitHub Packages 是私有的,你的本地终端(Terminal)和你的 CI 机器人(Actions)如何证明“我是我”?

GitHub 的认证体系在本地和云端是完全隔离的,这是新手最容易混淆的地方。

23.1.1. 本地开发专用:配置 PAT (Personal Access Token)

在 2021 年之后,GitHub 已经禁止使用账号密码在命令行登录。为了在本地电脑执行 docker pushnpm publish,我们需要一把“物理钥匙”。

操作步骤

  1. 登录 GitHub,点击头像 -> Settings -> 左侧最底部 Developer settings
  2. 选择 Personal access tokens -> Tokens (classic) -> Generate new token (classic)
  3. 权限勾选(关键一步)
    • write:packages:允许发布和上传包(勾选后会自动选中 read:packages)。
    • delete:packages:允许删除包的版本(强烈建议勾选,便于后续清理测试数据)。
    • repo:如果你的仓库是 Private(私有)的,必须勾选此项,否则无法读取源码。

image-20251204091216291

保存凭证:生成的 Token(以 ghp_ 开头)只显示一次。请立刻复制它

为了安全起见,我们不要每次都手动粘贴 Token,也不要将其写入代码。我们将其配置为系统环境变量。

终端配置(Mac/Linux/Git Bash)

1
2
3
4
5
# 将 Token 写入环境变量(替换为你的真实 Token)
export CR_PAT=ghp_你的真实Token字符串

# 验证变量是否生效
echo $CR_PAT

23.1.2. CI 环境专用:GITHUB_TOKEN

你的本地电脑有了 CR_PAT,那 GitHub Actions 的服务器怎么办?

不需要任何操作。GitHub 会为每一次 Workflow 运行动态生成一个一次性的 ${{ secrets.GITHUB_TOKEN }}。它比 PAT 更安全,因为它在运行结束后自动失效。我们将在稍后的 Pipeline 配置中直接使用它。


23.2. GHCR 实战:Spring Boot 镜像的私有化交付

现在我们有了钥匙,接下来构建第一个“货物”。我们将创建一个标准的 Spring Boot 项目,并将其打包推送到 GitHub Container Registry (GHCR)。

23.2.1. 初始化 Spring Boot 项目

首先,我们需要一个真实的后端工程。不要凭空想象,请跟随以下步骤操作:

步骤 1:创建项目
使用 curl 快速从 Spring Initializr 下载一个包含 Web 依赖的 Demo 项目,或者你也可以手动在 IDEA 中创建。

1
2
3
4
5
6
7
8
9
10
11
# 1. 下载脚手架 (Java 17, Maven, Web 依赖)
# 访问该网站填写信息
https://start.spring.io/

# 2. 解压并进入目录
unzip backend.zip -d backend-service
cd backend-service

# 3. 初始化 Git (必须步骤)
git init
git branch -M main

步骤 2:创建 Dockerfile
在项目根目录下新建 Dockerfile。在这里,我们需要引入 GHCR 的核心规范

文件路径Dockerfile

1
2
3
4
5
6
7
8
9
10
11
# 使用轻量级 JDK 基础镜像
FROM eclipse-temurin:17-jdk-alpine

# ⚠️ 关键知识点:元数据绑定
# GitHub Packages 依靠这个 LABEL 将“孤儿镜像”与“代码仓库”自动关联
# 请将下面的 URL 替换为你自己的 GitHub 仓库地址
LABEL org.opencontainers.image.source=https://github.com/YourUsername/backend-service

VOLUME /tmp
COPY target/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

为什么要有 LABEL?
如果你不写这行 LABEL,推送到 GHCR 的镜像将不会显示在你的仓库首页右侧,而是作为一个“无主”的 Package 散落在你的个人主页里,难以管理。

23.2.2. 编写自动化构建流水线

现在,我们要教 GitHub Actions 如何使用 GITHUB_TOKEN 登录 GHCR 并推送镜像。

步骤 1:创建 Workflow 文件
在项目根目录创建 .github/workflows/deploy-image.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
name: Build and Push Docker Image

on:
push:
branches: [ "main" ]

env:
# 规范:GHCR 镜像名必须是 ghcr.io/用户名/项目名
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
runs-on: ubuntu-latest
# ⚠️ 权限声明:这一步至关重要!
# 默认的 GITHUB_TOKEN 只有读权限,无法推送镜像
permissions:
contents: read
packages: write

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

# Maven 打包 (跳过测试以节省时间)
- name: Build with Maven
run: mvn clean package -DskipTests

# 1. 登录 GHCR
# 这里使用 secrets.GITHUB_TOKEN 自动登录,无需使用 PAT
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# 2. 生成多维度 Tags (latest, sha, etc.)
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

# 3. 构建并推送
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

步骤 2:提交并触发
将代码推送到 GitHub(请确保你已经在 GitHub 创建了对应的空仓库并关联了 remote)。

1
2
3
git add .
git commit -m "feat: init spring boot with docker pipeline"
git push -u origin main

前往 GitHub 仓库的 Actions 页面,等待构建成功。成功后,回到仓库首页(Code tab),你会惊喜地发现右侧边栏出现了一个 Packages 区域,里面列出了 backend-service 镜像。

image-20251204093828103

23.2.3. 本地验证:拉取私有镜像

现在镜像在云端了,我们在本地电脑上尝试拉取它。

错误示范:直接运行 docker pull ghcr.io/prorise-cool/testgithubpackage:main。你会收到报错:denied: denied,如果您遵循 23.1 节配置的环境变量,那么这则指令会自动成功

image-20251204094431059


23.3. NPM 实战:Vite 组件库的私有化发布

搞定了后端镜像,现在我们来挑战前端。场景是:你需要开发一个公司内部通用的 UI 组件库,发布到 GitHub Packages 供其他前端项目安装。

23.3.1. 初始化 Vite 项目与 Scope 规范

GitHub Packages 对 NPM 包有一个强制性的 “Scope(作用域)”规范。你不能发布一个叫 my-utils 的包,必须发布 @你的用户名/my-utils

步骤 1:创建项目
使用 Vite 快速初始化一个 TypeScript 库项目。

1
2
3
4
5
6
7
8
# 1. 创建项目
pnpm create vite ui-library --template react-ts

# 2. 进入目录
cd ui-library

# 3. 初始化 Git
git init

步骤 2:改造 package.json
打开 package.json,这是最关键的一步。我们需要告诉 NPM:“不要往 npmjs.com 推送,而是往 GitHub 推送”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
// ⚠️ 关键点 1:必须带 Scope,且必须与你的 GitHub 用户名一致
"name": "@YourUsername/ui-library",
"version": "1.0.0",
// ⚠️ 关键点 2:设为 false,否则无法发布
"private": false,
"type": "module",
"files": ["dist"],
"main": "./dist/ui-library.umd.cjs",
"module": "./dist/ui-library.js",
"exports": {
".": {
"import": "./dist/ui-library.js",
"require": "./dist/ui-library.umd.cjs"
}
},
// ⚠️ 关键点 3:指定发布时的注册表地址
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
// ⚠️ 关键点 4:关联仓库
"repository": {
"type": "git",
"url": "git+https://github.com/YourUsername/ui-library.git"
}
// ... 其他 build 脚本保持 Vite 默认即可
}

23.3.2. 配置 .npmrc:路由与鉴权的分离

配置了 package.json 只是告诉了 NPM “要去哪”,但没告诉它“怎么进门”。我们需要 .npmrc 文件。

场景一:项目级配置(路由映射)
在项目根目录创建 .npmrc 文件。这个文件会提交到 Git,所以 绝对不能包含 Token。它的作用是告诉安装依赖的人:“凡是 @YourUsername 开头的包,都去 GitHub 找”。

文件路径.npmrc

1
2
# 告诉 NPM 客户端,@YourUsername 作用域下的包,源地址是 GitHub
@YourUsername:registry=https://npm.pkg.github.com

场景二:用户级配置(本地鉴权)
在你的电脑主目录(~/.npmrc)下,配置刚才申请的 PAT。

1
2
3
# 在终端执行(追加配置到用户配置文件)
# 将 TOKEN 替换为你的真实 PAT
npm config set //npm.pkg.github.com/:_authToken=ghp_你的真实Token

23.3.3. 配置自动化发版 Actions

前端项目的发版通常不需要每次 Push 都触发,最符合语义化的工作流是:当你决定发布一个 Release 版本时,CI 自动将其推送到私有仓库

由于现在的项目大多转向了更高效的 pnpm,这里提供了两种主流包管理器的配置方案。

步骤 1:创建 Workflow 文件
新建 .github/workflows/npm-publish.yml,根据你的项目类型选择对应的配置:

如果你的项目使用原生 npm,使用此配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
name: Publish NPM Package

on:
release:
types: [created] # 触发条件:在 GitHub 页面点击 "Create a new release"

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # ⚠️ 必选:赋予 GITHUB_TOKEN 写包权限

steps:
- uses: actions/checkout@v4

# 1. 配置 Node 环境
- uses: actions/setup-node@v4
with:
node-version: '20'
# 关键:指定 registry-url,Action 会自动生成含 Auth 占位符的 .npmrc
registry-url: 'https://npm.pkg.github.com'
scope: '@YourUsername' # 替换为你的 Scope

# 2. 安装依赖 (使用 ci 命令以确保严格匹配 lock 文件)
- name: Install dependencies
run: npm ci

# 3. 构建产物
- name: Build
run: npm run build

# 4. 执行发布
# NODE_AUTH_TOKEN 是 setup-node 注入的特殊环境变量
# 它会自动替换 .npmrc 中的占位符,实现免密发布
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

如果你的项目使用 pnpm,需要额外安装 pnpm 环境,并处理特殊的发布参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
name: Publish PNPM Package

on:
release:
types: [created]

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

# 1. 安装 PNPM 环境 (GitHub Runner 默认只有 npm/yarn)
- uses: pnpm/action-setup@v3
with:
version: 9

# 2. 配置 Node 环境
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://npm.pkg.github.com'
scope: '@YourUsername'
# 开启 pnpm 缓存,加速构建
cache: 'pnpm'

# 3. 安装依赖 (使用 --frozen-lockfile 严防依赖漂移)
- name: Install dependencies
run: pnpm install --frozen-lockfile

# 4. 构建产物
- name: Build
run: pnpm run build

# 5. 执行发布
# --no-git-checks: 避免 CI 环境下因 git 状态检查导致的发布失败
- name: Publish
run: pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

步骤 2:执行发布全流程

  1. 本地准备:修改 package.json 中的 version(例如从 1.0.0 改为 1.0.1),并提交代码推送到 main 分支。
  2. 创建 Release
    • 在 GitHub 仓库首页点击右侧 Releases
    • 点击 Draft a new release
    • Choose a tag:输入 v1.0.1(建议与 package.json 版本保持一致)。
    • 点击 Publish release
  3. 验证结果
    • 跳转到 Actions 标签页,观察 Workflow 运行状态。
    • 成功后,回到仓库首页,右侧 Packages 列表应显示最新的版本。

image-20251204101902203

23.3. 本节小结

  • 双重身份:本地开发必须配置 ~/.npmrc (PAT),CI 环境利用 setup-node 配合 GITHUB_TOKEN 自动生成凭证。
  • Scope 铁律package.json 的 name 必须包含 @Scope,且必须与 publishConfig 配合使用。
  • 安全红线:项目根目录的 .npmrc 只做路由映射,严禁包含任何 _authToken 信息。

23.4. 消费闭环:在业务项目中安装私有依赖

在上一节,我们成功将 @YourUsername/ui-library 发布到了云端。但对于大多数开发者来说,“发包”只是完成了一半,真正的噩梦往往始于“装包”

这是一个典型的场景:你创建了一个新的业务项目(消费者),兴奋地在 package.json 里添加了依赖,结果执行 npm install 时,终端直接甩给你一个 404 Not Found 或者 401 Unauthorized

本节我们将解决这个工程化难题:如何在 B 项目中,安全地安装 A 项目发布的私有包?

23.4.1. 消费者视角的 .npmrc 配置

首先,我们需要告诉消费者项目(Consumer Project)两件事:

  1. 路由:遇到 @YourUsername 开头的包,别去 npm 官方源找,去 GitHub 找。
  2. 鉴权:我有合法的身份令牌。

步骤 1:创建项目级 .npmrc
在你的业务项目(例如 my-business-app)根目录下创建 .npmrc 文件。

文件路径my-business-app/.npmrc

1
2
3
4
5
6
7
# 1. 路由映射:明确告诉 npm 客户端,这个 Scope 归 GitHub 管
@YourUsername:registry=https://npm.pkg.github.com

# 2. 动态鉴权:引用环境变量中的 Token
# ⚠️ 注意:这里千万不要写死具体的 Token,而是使用变量占位符
# 这样既能适配本地环境(读取 ~/.npmrc),也能适配 CI 环境(读取环境变量)
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

步骤 2:本地安装测试
在本地开发时,由于你在 23.1 节已经在用户主目录(~/.npmrc)配置了全局 Token,且配置了 NODE_AUTH_TOKEN 环境变量(或 npm 客户端会自动回退读取 User Config),此时直接安装应该能成功。

1
2
3
4
# 本地安装私有依赖
pnpm install @YourUsername/ui-library@1.0.1
# 或者
pnpm add @YourUsername/ui-library@1.0.1

如果安装成功,说明路由配置正确。

23.4.2. CI/CD 中的跨仓库安装(Cross-Repo Access)

真正棘手的问题出现在 CI 流水线上。

my-business-app 的 GitHub Actions 运行时,它默认使用的 GITHUB_TOKEN 仅拥有当前仓库的权限。它没有权限去读取你另一个仓库(如 ui-library)里的 Packages。

这就是著名的“跨仓库访问(Cross-Repository Access)”死结。

解决方案:使用 PAT 破局

我们需要将在 23.1 节申请的那个“万能钥匙”(PAT),作为 Secret 注入到业务项目中。

  1. 存储 Secret

    • 进入 my-business-app 仓库的 Settings -> Secrets and variables -> Actions
    • 点击 New repository secret
    • Name: PACKAGES_READ_TOKEN (名字清晰即可)。
    • Value: 粘贴你的 PAT(必须包含 read:packages 权限)。
  2. 配置 Workflow:修改业务项目的构建流程,将这个 Secret 注入给包管理器。这里同样提供 NPM 和 PNPM 两种配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
name: Build Business App (NPM)

on: [push]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# 1. 配置 Node 环境
- uses: actions/setup-node@v4
with:
node-version: '20'
# 虽然这里不发布,但必须声明 registry 和 scope
# 这样 Action 才会生成正确的 .npmrc 路由规则
registry-url: 'https://npm.pkg.github.com'
scope: '@YourUsername'

# 2. 安装依赖
# ⚠️ 关键点:将我们存储的 PAT (PACKAGES_READ_TOKEN) 赋值给环境变量
# setup-node 生成的 .npmrc 会读取这个变量进行鉴权
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN }}

- name: Build App
run: npm run build
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
name: Build Business App (PNPM)

on: [push]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# 1. 安装 PNPM
- uses: pnpm/action-setup@v3
with:
version: 9

# 2. 配置 Node 环境
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://npm.pkg.github.com'
scope: '@YourUsername'
cache: 'pnpm'

# 3. 安装依赖
# PNPM 同样会遵循 .npmrc 的鉴权规则
# 只要环境变量中有 NODE_AUTH_TOKEN,就能成功拉取私有包
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN }}

- name: Build App
run: pnpm run build

原理总结:我们绕过了默认的 GITHUB_TOKEN,而是强制 npm/pnpm 客户端使用我们拥有更高权限的 PAT。这样,无论私有包在哪个仓库,只要该 PAT 有读取权,CI 就能顺利拉取。


23.5. 治理与维护:防止存储爆炸

GitHub Packages 的免费额度通常只有 500MB(Free 账号)或 2GB(Pro 账号)。对于 Docker 镜像来说,这简直是杯水车薪。一个未优化的 Spring Boot 镜像可能就有 200MB,几次构建就能把空间撑爆。

一旦超额,你的 Actions 就会报错,甚至导致无法推送新代码。我们需要引入“自动垃圾回收机制”。

23.5.1. 存储空间预警

你可以在 Settings -> Billing and plans 中查看当前的存储用量。如果发现红色预警,第一反应通常是去 Packages 页面手动删除。

但手动删除非常痛苦:你需要点进每一个 Version,点击 Settings,输入确认码,点击删除…重复 50 次。

23.5.2. 自动化清理策略

我们将使用官方推荐的 actions/delete-package-versions 来实现自动化治理。

策略设计

  • 保留:最近发布的 3 个正式版本(Release)。
  • 删除:所有的开发版镜像(Snapshot/dev),或者超过 30 天的旧镜像。
  • 触发:每天凌晨自动执行,或在推送代码后触发。

实战配置:在 backend-service(Docker 项目)中添加清理流程。

文件路径.github/workflows/cleanup-packages.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
name: Cleanup Old Images

on:
# 1. 允许手动触发 (方便测试)
workflow_dispatch:
# 2. 每天凌晨 1 点自动执行
schedule:
- cron: '0 1 * * *'

jobs:
delete-versions:
runs-on: ubuntu-latest
permissions:
packages: write

steps:
- name: Delete old image versions
uses: actions/delete-package-versions@v5
with:
# 指定要清理的包名称
package-name: 'backend-service'
package-type: 'container'

# 策略:保留最新的 5 个版本,其余删除
min-versions-to-keep: 5

# 策略:即使是最新版本,如果超过 30 天没人用也删除 (慎用)
# delete-only-untagged-versions: "true" # 建议开启此项,只删除无 Tag 的中间层镜像

# 忽略特定 Tag (例如 latest 和 v1.0.0 这种正式版)
ignore-versions: '^(latest|v.*)$'

关键参数解释

  • delete-only-untagged-versions: Docker 构建过程中会产生很多 <none> 标签的中间层镜像,这些是占用空间的元凶。将此项设为 true 是最安全的瘦身方式。
  • ignore-versions: 使用正则保护你的生产环境 Tag,防止误删。

23.6. 本章总结

私有制品库是企业级工程化的分水岭。通过本章的学习,你不仅学会了如何发布包,更重要的是掌握了这一整套复杂的鉴权逻辑。

23.6.1. 核心知识体系

场景关键痛点解决方案核心配置/命令
本地发布/拉取密码登录已废弃使用 Classic PATdocker login -u user -p PAT
CI 发布 (Producer)默认权限不足使用 GITHUB_TOKENpermissions: packages: write
CI 安装 (Consumer)跨仓库无权限使用 PAT (Secret).npmrc + NODE_AUTH_TOKEN
制品关联镜像无法显示在仓库页缺少 OCI 元数据LABEL org.opencontainers.image.source
存储治理免费额度耗尽自动化清理actions/delete-package-versions

23.6.2. 常见问题速查 (FAQ)

  1. Q: 为什么我推送到 GHCR 的镜像在 Packages 页面能看到,但仓库主页右侧没有?

    • A: 99% 是因为 Dockerfile 里漏写了 LABEL org.opencontainers.image.source=...。补上后重新构建推送即可。
  2. Q: 消费端项目报错 401 Unauthorized,但我已经在 .npmrc 里配置 Token 了?

    • A: 检查 .npmrc 中的 Token 引用方式。如果写的是 ${NODE_AUTH_TOKEN},请确保你的环境变量里真的有这个值。在本地,你需要检查 ~/.npmrc 是否有对应 Scope 的 Auth 配置。
  3. Q: 可以将私有包变成公开包吗?

    • A: 可以。进入 Package 的 Settings 页面,滑动到底部 Danger Zone,点击 “Change visibility”。注意,一旦公开,任何人都可以下载你的代码。

恭喜你!现在你已经拥有了一套完整的软件交付供应链。你的代码不再只是一堆文本,而是可以被自动打包、分发、版本化管理的工业级制品。接下来,我们将进入 GitHub CLI 的世界,看看如何摆脱浏览器,在终端里掌控一切。