MacPorts -- 创建自己的 Port

摘要

  • Portfile 是 MacPorts 的安装脚本,对应 Homebrew 的 formula。官方 ports 树里没有的软件,可以放在本机的一个目录里,在 sources.conf 登记之后,用同样的 port install 安装。
  • 本地目录要写成 file://,放在带 [default] 的那一行前面,并加上 [nosync]。Portfile 放在 分类/名字/Portfile。改完在仓库根目录执行 portindex。
  • 下面用 brew -- 创建自己的 Formula 里的 color_echo 1.0.0 作例子。源码是 GitHub tag v1.0.0 的归档,命令文件在 bin/color_echo。安装和日常命令见 MacOS软件包管理器--MacPorts。没有 brew tap 这种命令,个人仓库是一份 Git 上的 ports 树,别人克隆后写进自己的 sources.conf。
  • 本机 MacPorts 是 2.12.7。sources.conf 里的默认行是 ports.tar.gz,不是指南旧示例里的 ports.tar。

本地仓库

  • 官方 ports 树由 selfupdate 覆盖。自己的 Portfile 放在另一个目录,port sync 不去动它。步骤见 Local Portfile Repositories。

  • 编辑 /opt/local/etc/macports/sources.conf。file:// 写在带 [default] 的那一行前面。同一个名字出现多次时,MacPorts 用先出现的那一份。[nosync] 让 selfupdate 跳过这个目录。本机改完是:

1
2
file:///Users/hanqf/ports [nosync]
rsync://rsync.macports.org/macports/release/tarballs/ports.tar.gz [default]
  • 目录名的第一层是 Portfile 里的主分类,第二层是 port 名字。color_echo 放在 sysutils:

1
mkdir -p ~/ports/sysutils/color_echo

Portfile

  • 文件是 Tcl。第一行是编辑器模式标记,后面先声明 PortSystem,GitHub 上的项目再加 github 1.0。字段说明见 Portfile Reference,GitHub 这一组见 PortGroups 里的 github。

  • github.setup 的四个参数是作者、仓库、版本、tag 前缀。1.0.0 配 v,tag 就是 v1.0.0。github.tarball_from 默认是 releases,读的是 Releases 页面上挂的附件。color_echo 用的是 tag 归档,要改成 archive,地址才是 https://github.com/hanqunfeng/color_echo/archive/refs/tags/v1.0.0.tar.gz。

  • 这份归档解压后的目录是 color_echo-1.0.0。archive 会打开 extract.rename,工作目录和这个名字对齐。脚本在 bin/color_echo,没有编译步骤。

  • 创建 ~/ports/sysutils/color_echo/Portfile 文件:

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
# -*- coding: utf-8; mode: tcl; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- vim:ft=Portfile:et:sw=4:ts=4:sts=4

PortSystem 1.0
PortGroup github 1.0

github.setup hanqunfeng color_echo 1.0.0 v
github.tarball_from archive
revision 0

categories sysutils
platforms any
supported_archs noarch
license MIT
maintainers nomaintainer

description Print colored text in the terminal
long_description {*}${description}

checksums rmd160 0123456789abcdef0123456789abcdef01234567 \
sha256 9450952a4b477c83ea2d7e28386d6ae38132bf68c46746aa218c03c21aa75f6d \
size 0

use_configure no
build {}

destroot {
xinstall -m 0755 ${worksrcpath}/bin/color_echo \
${destroot}${prefix}/bin/color_echo
}
  • 各字段含义如下:

    • 第一行:给编辑器看的模式行,标明这是 Tcl / Portfile,缩进 4 空格;MacPorts 本身不解析它。
    • PortSystem 1.0:启用 Portfile 基础设施,每个 Portfile 都要写。
    • PortGroup github 1.0:引入 GitHub 相关辅助命令,后面才能用 github.setup、github.tarball_from。
    • github.setup:四个参数依次是 GitHub 作者、仓库名、版本号、tag 前缀。这里是 hanqunfeng / color_echo / 1.0.0 / v,对应 tag v1.0.0。
    • github.tarball_from archive:从 tag 归档下载;默认 releases 会去 Releases 附件,对不上时要改成 archive。
    • revision:同一上游版本下 Portfile 自己的修订号。只改安装步骤时加 1;换了上游版本则改 github.setup 里的版本,并把 revision 回到 0。
    • categories:软件分类,第一项应和本地目录名一致,这里是 sysutils。
    • platforms any:不限运行平台;纯脚本常见写法。
    • supported_archs noarch:没有按 CPU 架构区分的二进制,arm64 / x86_64 共用同一份。
    • license:上游许可证,这里是 MIT。
    • maintainers:维护者;只在本机用可写 nomaintainer。
    • description:一句话短描述,出现在 port search、port info。
    • long_description:长描述。{*}${description} 表示直接展开复用短描述。
    • checksums:校验下载到的压缩包。rmd160、sha256 是摘要,size 是字节数;三个都针对 .tar.gz 本身。
    • use_configure no:跳过 ./configure。
    • build {}:空的编译阶段,表示没有编译步骤。
    • destroot { ... }:安装阶段。${worksrcpath} 是解压后的源码目录,xinstall 把 bin/color_echo 复制成可执行文件。复制目标是 ${destroot}${prefix}/bin/color_echo,两个变量的含义见下面。
  • sha256 与 brew -- 创建自己的 Formula 里同一份 v1.0.0.tar.gz 一致。rmd160 和 size 先占位,下一步用本机算出来的值换掉。只给自己用时,maintainers 写 nomaintainer 即可。

  • ${prefix} 是装好之后的前缀。本机是 /opt/local,所以这条命令最后出现在 /opt/local/bin/color_echo。

  • ${destroot} 是这一次安装的临时目录,在 /opt/local/var/macports/build/ 下面、这个 port 的 work/destroot。它当时还是空的,里面会按前缀再套一层目录。

  • 两个变量连着写,${destroot}${prefix} 就是「临时目录 + /opt/local」。这条 xinstall 的目标是:

1
/opt/local/var/macports/build/……/work/destroot/opt/local/bin/color_echo
  • MacPorts 先在这个临时目录里装完,记下相对 ${destroot} 的路径,再把 destroot/opt/local/ 下面的内容移到真正的 /opt/local。卸载时按这份记录删除。文件在 destroot 阶段就直接写进 /opt/local/bin,登记里没有它,以后 port uninstall 不会把它删掉。

校验和

  • 三个值都是压缩包本身的,不是解压后的文件:

1
2
3
4
5
curl -fL -o ~/Downloads/color_echo-1.0.0.tar.gz \
'https://github.com/hanqunfeng/color_echo/archive/refs/tags/v1.0.0.tar.gz' # 下载 tag 对应的源码归档
openssl dgst -sha256 ~/Downloads/color_echo-1.0.0.tar.gz # 算 sha256,写入 Portfile 的 sha256
openssl dgst -rmd160 ~/Downloads/color_echo-1.0.0.tar.gz # 算 rmd160,写入 Portfile 的 rmd160
stat -f%z ~/Downloads/color_echo-1.0.0.tar.gz # 取字节数,写入 Portfile 的 size
  • sha256 的结果应是 9450952a4b477c83ea2d7e28386d6ae38132bf68c46746aa218c03c21aa75f6d。rmd160 写进 Portfile 的第一段,stat 的字节数写到 size。

  • 也可以先按上面的占位保存,再执行 sudo port -v checksum color_echo。对不上时,终端会给出算出来的 rmd160、sha256 和 size。改完 Portfile 后重新 portindex。

索引、检查、安装

  • 在仓库根目录重建索引。这个目录属于自己,不用 sudo:

1
2
3
4
5
6
7
8
9
10
cd ~/ports
portindex
# 输出
Creating port index in /Users/hanqf/ports
Adding port sysutils/color_echo

Total number of ports parsed: 1
Ports successfully parsed: 1
Ports failed: 0
Up-to-date ports skipped: 0
  • 索引建成之后,port 能看到它。port file 用来确认读的是本地这一份:

1
2
3
4
5
port info color_echo          # 查看版本、描述、依赖等
port file color_echo # 确认读的是本地 Portfile
# /Users/hanqf/ports/sysutils/color_echo/Portfile

port lint --nitpick color_echo # 严格检查必填字段、依赖与过时写法
  • lint 检查必填字段、依赖是否存在、有没有过时写法。通过后再装:

1
2
3
sudo port install color_echo  # 安装本地 Portfile 定义的 color_echo
ls -l /opt/local/bin/color_echo
port contents color_echo # 查看这个包装了哪些文件
  • port install 把命令写到 /opt/local/bin,不改当前终端已经读入的 PATH。which 只在这份 PATH 里找。这个终端若是在写好 ~/.zprofile、~/.zshrc 之前打开的,或者不是登录 shell、没读到 ~/.zprofile,which color_echo 会显示找不到,文件其实已经在磁盘上。ls 能看到它,就说明装上了。

  • 新开一个终端再查。PATH 里已经有 /opt/local/bin 时,同一终端里安装后 which 也能看到。结果应是 /opt/local/bin/color_echo。同名命令排在更前面时,见 MacOS软件包管理器--MacPorts 里 ~/.zshrc 末尾再声明 MacPorts 路径的那一节。

1
2
which color_echo
color_echo --help

改过之后

  • 只改了安装步骤、版本号没变,把 revision 加 1。换了上游版本,就改 github.setup 里的版本,并重算校验和,revision 回到 0。

  • Portfile 改完都要重新索引,再升级:

1
2
3
cd ~/ports
portindex
sudo port upgrade color_echo
  • 不再需要时:

1
sudo port uninstall color_echo

个人仓库

  • MacPorts 没有 brew tap user/repo。能分发给别人的,是一个目录结构和本机 ~/ports 相同的 Git 仓库:分类/名字/Portfile。别人克隆下来,在自己的 sources.conf 里加一行 file://,就能 port install。说明见 sources.conf。

  • 把已经写好的 ~/ports 推上去即可。PortIndex 由使用者本机生成,不必提交:

1
2
3
4
5
6
cd ~/ports
git init
git add sysutils/color_echo/Portfile
git commit -m "color_echo: new port"
git remote add origin https://github.com/hanqunfeng/my-ports.git
git push -u origin main
  • 使用者克隆后做一次索引,再把绝对路径写到带 [default] 的那一行前面:

1
2
3
git clone https://github.com/hanqunfeng/my-ports.git ~/my-ports
cd ~/my-ports
portindex
1
2
file:///Users/hanqf/my-ports [nosync]
rsync://rsync.macports.org/macports/release/tarballs/ports.tar.gz [default]
  • [nosync] 时,selfupdate 不会碰这个目录。仓库有更新时,自己 git pull,再在仓库根目录执行 portindex。

  • 去掉 [nosync] 后,selfupdate 发现这是 Git 检出,会向默认远程 pull,并重建索引。执行同步的是 macports 用户,这个目录要让它可写。家目录通常不行,检出放到它能写的路径,例如 /opt/local/var/macports/sources/github.com/hanqunfeng/my-ports。

提交到官方仓库

  • 个人仓库要别人改自己的 sources.conf 才能用。希望不改配置、一条 port install color_echo 就能装到,把 Portfile 交给 macports-ports。步骤就是 fork、单独一个分支、一条提交、开 pull request。耗时间的是这个仓库本身很大,以及 GitHub Actions 编译和别人审 Portfile。Portfile 写法见 Creating a Portfile,提交说明见 CommitMessages 和 Using Git and GitHub。

  • 提交前在本机走通 port lint --nitpick color_echo 和 sudo port install color_echo。准备自己维护这个 port,把 maintainers nomaintainer 改成 maintainers {@hanqunfeng}。留给别人维护就保持 nomaintainer。

  • 官方仓库是 macports/macports-ports。没有推送权限,直接克隆官方地址后,git push 会被拒绝。先在 GitHub 上点 Fork,得到自己的 hanqunfeng/macports-ports,再克隆这一份。只克隆一层历史,避免把整个 ports 树的历史都拉下来:

1
2
3
4
5
git clone --depth 1 https://github.com/hanqunfeng/macports-ports.git
cd macports-ports
git checkout -b color_echo
mkdir -p sysutils/color_echo
cp ~/ports/sysutils/color_echo/Portfile sysutils/color_echo/Portfile
  • 只加入 Portfile。portindex 生成的 PortIndex 属于本机索引,不放进仓库。提交标题先写 port 名字:

1
2
3
git add sysutils/color_echo/Portfile
git commit -m "color_echo: new port"
git push -u origin color_echo
  • 在 GitHub 上对 macports/macports-ports 的 master 开 pull request。Actions 会按这份 Portfile 编译。本例的 pull request

如果本机安装了gh命令,也可以通过如下命令创建pull request

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
gh pr create --repo macports/macports-ports \
--base master \
--head hanqunfeng:color_echo \
--title "color_echo: new port" \
--body "$(cat <<'EOF'
#### Description

New port: print colorized echo messages from the command line.

This is not a pre-built binary. The distfile is the Git tag archive, and bin/color_echo is a zsh script (it starts with #!/usr/bin/env zsh). There is no compile step, so the Portfile uses build {} and installs that script. The source of the program is the script itself.

###### Type(s)
<!-- update (title contains ": U(u)pdate to"), submission (new Portfile) and CVE Identifiers are auto-detected, replace [ ] with [x] to select -->

- [ ] bugfix
- [ ] enhancement
- [ ] security fix

###### Tested on
macOS 15.8.1 24H32 x86_64
Command Line Tools 26.3.0.0.1.1771626560

###### Verification <!-- (delete not applicable items) -->
Have you

- [x] followed our [Commit Message Guidelines](https://trac.macports.org/wiki/CommitMessages)?
- [x] squashed and [minimized your commits](https://guide.macports.org/#project.github)?
- [x] checked that there aren't other open [pull requests](https://github.com/macports/macports-ports/pulls) for the same change?
- [x] checked your Portfile with `port lint`?
- [x] tried a full install with `sudo port -vst install`?
- [x] tested basic functionality of all binary files?

<!-- Use "skip notification" (surrounded with []) to avoid notifying maintainers -->

EOF
)"
  • 审查意见要改文件时,改在同一条提交上再推:

1
2
3
4
5
6
7
8
# 重新添加修改后的 Portfile 到暂存区
git add sysutils/color_echo/Portfile
# 用 --amend 将更改合并到上一次提交(保持提交历史简洁),不修改提交说明
git commit --amend --no-edit
# 若需同步最新主仓库的提交,可获取远程分支,--depth=1 表示仅获取最近的一次提交
git fetch --depth=1 origin java-project-generator
# 强制推送本地的 color_echo 分支(带保护机制),以便 PR 自动更新
git push --force-with-lease=color_echo:$(git rev-parse FETCH_HEAD)
  • 审查者要求修改时,可能把 pull request 标成 draft。这不是拒绝,请求还开着,状态仍是 Changes requested。标成 draft 只是先移出待合并队列,表示现在还不能合并。draft 期间对方通常不会再审,GitHub Actions 也可能先不跑。

  • 解释或修改写在 pull request 的评论里,不要回通知邮件。Portfile 不用改、评论已经写完时,在页面上点 Ready for review,请求回到待审列表,对方才能接着看这条评论。在这之前它会一直停在草稿里。改过 Portfile 并推送之后,同样要点 Ready for review。

  • 几天没有回复,可以给 macports-dev 写信,信里放上这个 pull request 的链接。合并之后,selfupdate 就能从官方树里装到 color_echo,本机 sources.conf 里的 file:// 那一行可以删掉。本地目录排在 [default] 前面时,同名 port 仍会用本地这份。

上面这个示例就是一个玩具,大概率不会被官方收入到主仓库中,下面给出一个相对完整的示例

一个更加完整的示例

  • 以下中文注释是为了解释说明,不建议把现在这些中文注释提交到官方仓库,是因为 macports-ports 的惯例是英文。审核者按英文阅读 Portfile,大段教学注释也会被要求删掉。

  • 本例的 pull request

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# -*- coding: utf-8; mode: tcl; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- vim:fenc=utf-8:ft=tcl:et:sw=4:ts=4:sts=4

# PortSystem: 声明 Portfile 语法版本,目前固定为 1.0。
PortSystem 1.0

# PortGroup: 引入一组预置逻辑。github 1.0 负责从 GitHub 取源码并做版本检查。
PortGroup github 1.0

# github.setup: 作者、仓库名、版本号、tag 前缀。这里的 v 表示 tag 是 v1.0.9。
github.setup hanqunfeng java-project-generator 1.0.9 v
# github.tarball_from: 指定源码包从哪下载。archive 使用 GitHub 的 tag 归档,校验值稳定。
github.tarball_from archive
# revision: 同一上游版本下的 Portfile 修订号。只改安装脚本、不改版本时加 1。
revision 0

# description: 一行简介。搜索结果和 port info 会显示它。
description Generate Spring Boot projects quickly with shell scripts
# long_description: 详细说明。{*}${description} 把上面的简介展开后接在这段文字前面。
long_description {*}${description}. Supports single-module jar/war projects \
via Spring Initializr, and multi-module Maven or Gradle \
projects, including adding modules to an existing build.

# categories: 端口分类。第一个是主分类,必须和 Portfile 所在目录名一致。
categories devel java
# platforms: 可安装的系统范围。any 表示不限制 macOS 版本。
platforms any
# supported_archs: 支持的 CPU 架构。noarch 表示纯脚本,不区分 x86_64 和 arm64。
supported_archs noarch
# license: 上游项目的许可证。
license MIT
# maintainers: 维护者。openmaintainer 表示其他维护者也可以提交更新。
maintainers {@hanqunfeng github:hanqunfeng} openmaintainer
# installs_libs: no 表示本端口不安装共享库,rev-upgrade 不必扫描它。
installs_libs no

# checksums: 校验下载到的源码包。rmd160 与 sha256 核对内容,size 核对字节数。
checksums rmd160 9c39d549f3f56a2642bcd0a45626a7fce18b8d8a \
sha256 d648d8ab75cc2b961c19c1cb9c281afbbfda6b21c767b2c607cfbb52ce24ca70 \
size 59763

# depends_run-append: 追加运行时依赖。安装本端口时会先装上,编译阶段用不到。
depends_run-append port:glow

# use_configure: no 表示没有配置阶段,不运行 ./configure。
use_configure no
# build: 空过程表示跳过编译。默认的 build 会执行 make。
build {}

destroot {
# set: 给 Tcl 变量赋值。${prefix} 是安装前缀,${name} 是端口名。
# 这里得到的是激活后的真实路径,不含 ${destroot}。
set libexec ${prefix}/libexec/${name}

# xinstall -d: 创建目录,父目录不存在时一并创建。文件必须写到 ${destroot} 暂存树里。
xinstall -d ${destroot}${libexec}
# xinstall -m: 把文件拷进目标目录,并用 -m 设置权限。0755 表示所有者可读写执行。
# ${worksrcpath} 是解压后的源码目录。
xinstall -m 0755 ${worksrcpath}/springboot ${destroot}${libexec}

xinstall -d ${destroot}${libexec}/lib
# glob: 按通配符列出文件。{*}: 把这个列表拆成一个个独立参数再传给 xinstall。
xinstall -m 0755 {*}[glob ${worksrcpath}/lib/*.sh] ${destroot}${libexec}/lib

xinstall -d ${destroot}${libexec}/completions
# -W: 后面列出的源文件名都相对于这个目录。0644 表示所有者可读写,其他人只读。
xinstall -m 0644 -W ${worksrcpath}/completions springboot.bash _springboot \
${destroot}${libexec}/completions

xinstall -d ${destroot}${libexec}/assets
xinstall -m 0644 {*}[glob ${worksrcpath}/assets/*] ${destroot}${libexec}/assets

# -d 与 -m 合用: 创建目录时直接指定权限。1777 所有用户可写,并带粘滞位。
# MacPorts 以 root 安装,之后运行 springboot 的用户必须能写缓存。
# 补全脚本按相对路径查找 deps-cache,所以缓存留在这棵 libexec 树里。
xinstall -d -m 1777 ${destroot}${libexec}/deps-cache

xinstall -d ${destroot}${prefix}/bin
# open: 打开文件并返回通道。w 表示新建或清空后写入。
set wrapper [open ${destroot}${prefix}/bin/springboot w]
# puts: 向通道写入一行。内容里的路径用 ${prefix},这是用户激活后真正执行的位置。
puts ${wrapper} "#!/usr/bin/env bash"
puts ${wrapper} "exec \"${libexec}/springboot\" \"\$@\""
# close: 关闭通道,内容此时落盘。
close ${wrapper}
# file attributes -permissions: 修改已经存在的文件的权限。
file attributes ${destroot}${prefix}/bin/springboot -permissions 0755

# 补全入口只 source libexec 里的脚本,这样 BASH_SOURCE 仍落在 deps-cache 旁边。
set bash_comp_dir ${destroot}${prefix}/share/bash-completion/completions
xinstall -d ${bash_comp_dir}
set bash_comp [open ${bash_comp_dir}/springboot w]
puts ${bash_comp} "source \"${libexec}/completions/springboot.bash\""
close ${bash_comp}
file attributes ${bash_comp_dir}/springboot -permissions 0644

# _springboot 里的 :A 会把符号链接解析回 libexec。
xinstall -d ${destroot}${prefix}/share/zsh/site-functions
# ln -s: 创建符号链接。链接目标写安装后的路径,不要带 ${destroot}。
ln -s ${libexec}/completions/_springboot \
${destroot}${prefix}/share/zsh/site-functions/_springboot
}

# notes: 安装完成后打印给用户的说明,不参与构建。
notes "
python3 is required but is not installed by this port. Install it before use:

sudo port install python313
sudo port select --set python3 python313

Needed for: deps list cache, --deps validation, and other Initializr metadata parsing.

Optional tools (not installed by this port):

sudo port install pandoc
sudo port install jq
sudo port install libxml2
sudo port install xmlstarlet
sudo port install maven3
sudo port select --set maven maven3

pandoc is only used by \"springboot deps list --output=web\".
xmllint, xmlstarlet, and mvn are only used when parsing a parent POM for Maven\
\"module add\". An awk fallback still works without them.

Network access to Spring Initializr is required (default: https://start.spring.io).
Override with: export INITIALIZR_BASE_URL=<mirror-url>

Bash completion is installed for the bash-completion port. Zsh completion is\
installed to ${prefix}/share/zsh/site-functions.
"

# test.run: yes 表示允许执行 port test。默认不跑测试。
test.run yes
# test: 自定义测试阶段,用来代替默认的 make test。
test {
# system: 通过 shell 执行一条命令。-W 指定工作目录,命令失败则测试失败。
system -W ${worksrcpath} "set -o pipefail; ./springboot --help | grep -F 'springboot <command>'"
system -W ${workpath} "set -o pipefail; ${worksrcpath}/springboot create --name=porttest --dry-run | grep -F 'Dry-run 模式'"
}
  • destroot 中的指令

指令 作用
set 给 Tcl 变量赋值
xinstall -d 创建目录
xinstall -m 拷贝文件并设置权限
glob 与 {*} 按通配符列出文件,再拆成独立参数
xinstall -W 源文件名相对于指定目录
xinstall -d -m 创建目录时直接指定权限
open 打开文件通道
puts 向通道写入一行
close 关闭通道并落盘
file attributes -permissions 修改已有文件的权限
ln -s 创建符号链接