brew -- 创建自己的 Cask

摘要

  • brew 用 formula 从源码(或 bottle)安装软件,用 cask 安装已经打好的成品。成品常见是 .app / .pkg,也可以只是一个命令行二进制,不一定非要是 GUI。

  • 本文介绍如何在 macos 下创建自己的 Cask,和 brew -- 创建自己的 Formula 是一对:那篇讲 Formula,这篇讲 Cask。

  • 本文基于 MacOS 15.x,brew 版本为 Homebrew 7.0.4。

  • 关于 brew 的安装及使用可以参考 MacOS软件包管理器--brew

Formula 和 Cask 的区别

  • 选择哪种包装,看的是怎么拿到软件,不是「命令行必须 Formula、GUI 必须 Cask」:

    • 能从源码构建(开源命令行工具、库)→ Formula
    • 上游已经提供预编译包 → Cask(可以是 .app / .pkg,也可以是单个命令文件)
对比项 Formula Cask
典型对象 从源码构建的命令行工具、库 预编译成品:GUI 或命令行二进制都可以
下载物 源码 tarball,本地编译或 bottle .dmg / .zip / .tar.gz / .pkg 等容器
安装产物 Cellar 里的文件,再软链到 $(brew --prefix)/bin app → /Applications;pkg → 系统 installer;binary → $(brew --prefix)/bin
DSL class Xxx < Formula cask "token" do ... end
文件位置 Formula/xxx.rb Casks/xxx.rb
安装命令 brew install xxx brew install --cask xxx
卸载 brew uninstall xxx brew uninstall --cask xxx,可用 --zap 清残留
  • .dmg / .zip / .tar.gz 只是下载容器,真正装进系统的是 artifact:app、pkg、binary 等。Reasonix 就是 Cask 只装一个命令文件的例子,见文末。

  • 自己写的、需要从源码安装的命令行脚本仍应走 Formula。已经有各平台预编译二进制时,用 Cask 的 binary 更合适。

从一个简单示例开始

  • 这里用一个最小的 macOS 应用 HelloCask.app 做演示:用 AppleScript 生成 .app,打成 zip,上传到 GitHub Release,再写 Cask 让别人 brew install --cask 安装。

  • 和 Formula 的关键差别:Formula 通常下载源码包(archive/refs/tags/v1.0.0.tar.gz);Cask 下载的是已经打好的应用包。

制作 HelloCask.app 并发布

  • 创建一个 Github 仓库,用于存放发布产物,仓库名称:hanqunfeng/hello_cask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 用 AppleScript 生成一个最简单的 .app,双击会弹出对话框
osacompile -o HelloCask.app -e 'display dialog "Hello Cask!" buttons {"OK"} default button "OK"'

# 查看生成结果
ls HelloCask.app/Contents
# Info.plist MacOS Resources

# 打包,--keepParent 会保留 HelloCask.app 这一层目录,Cask 里才能写 app "HelloCask.app"
ditto -c -k --keepParent HelloCask.app HelloCask-1.0.0.zip

# 计算 sha256,后面写 Cask 时要用
shasum -a 256 HelloCask-1.0.0.zip
## 输出示例(以你自己算出来的为准)
# 745717e0025690a54a05ea2401987944de3d83fa3cf3f838d229313c434986a7 HelloCask-1.0.0.zip
  • 把仓库推到 GitHub,然后在 GitHub 上创建一个 Release:

    • Tag:v1.0.0
    • 附件上传:HelloCask-1.0.0.zip
  • 下载地址会是:

1
https://github.com/hanqunfeng/hello_cask/releases/download/v1.0.0/HelloCask-1.0.0.zip

不要用 archive/refs/tags/v1.0.0.tar.gz 这种源码归档当地址。Cask 不会帮你编译 .app,它只负责下载、校验、把里面的 .app 拷到 /Applications。

创建 tap 仓库

  • Cask 和 Formula 可以放在同一个 tap 里(Formula/ 和 Casks/ 并列),也可以单独建一个。这里单独建,结构和上一篇保持一致。

1
2
3
4
5
6
7
8
9
10
11
brew tap-new hanqunfeng/hello_cask
## 输出
Warning: tap-new is a developer command, so Homebrew's
developer mode has been automatically turned on.
To turn developer mode off, run:
brew developer off

Initialized empty Git repository in /usr/local/Homebrew/Library/Taps/hanqunfeng/homebrew-hello_cask/.git/
[main (root-commit) ...] Create hanqunfeng/hello_cask tap
==> Created hanqunfeng/hello_cask
/usr/local/Homebrew/Library/Taps/hanqunfeng/homebrew-hello_cask
  • brew tap-new 默认只创建 Formula/ 目录,Cask 需要自己补:

1
mkdir -p "$(brew --repo hanqunfeng/hello_cask)/Casks"

仓库在 GitHub 上的名字必须是 homebrew-hello_cask。brew tap hanqunfeng/hello_cask 会自动补上 homebrew- 前缀。

手写一个 Cask 文件

  • 真正有约束的是 token 三处一致:

    • 文件名:Casks/hello-cask.rb
    • header:cask "hello-cask" do
    • 安装命令:brew install --cask hello-cask
  • name "HelloCask" 是给人看的显示名,可以有大小写和空格,不必等于 token。

  • brew generate-cask-token 不是安装所必需的步骤。它只是按官方规则给一个建议名,方便向 homebrew/cask 投稿、避免重名。个人 tap 里你自己定 hello-cask 完全可以,不必先跑这个命令。

  • 如果要用它,注意它只做「小写 + 空格/下划线变 -」,不会拆驼峰:

1
2
3
4
5
6
7
8
9
10
11
brew generate-cask-token "HelloCask"
## 实际输出(驼峰不会被拆开)
Proposed token: hellocask
Proposed file name: hellocask.rb
Cask Header Line: cask "hellocask" do

brew generate-cask-token "Hello Cask"
## 实际输出(有空格才会变成 hello-cask)
Proposed token: hello-cask
Proposed file name: hello-cask.rb
Cask Header Line: cask "hello-cask" do
  • 后面示例采用 hello-cask。选定之后,文件名和 header 不要混用 hellocask。

1
2
cd "$(brew --repo hanqunfeng/hello_cask)/Casks"
touch hello-cask.rb

写入内容(把 sha256 换成你自己算出来的值):

1
2
3
4
5
6
7
8
9
10
11
12
13
cask "hello-cask" do
version "1.0.0"
sha256 "745717e0025690a54a05ea2401987944de3d83fa3cf3f838d229313c434986a7"

url "https://github.com/hanqunfeng/hello_cask/releases/download/v#{version}/HelloCask-#{version}.zip"
name "HelloCask"
desc "Tiny demo app for learning Homebrew Casks"
homepage "https://github.com/hanqunfeng/hello_cask"

depends_on :macos

app "HelloCask.app"
end
  • desc 不要以 A/An/The 开头,也不要写 macOS,否则 brew style 会报错。

  • 只有 .app 的 Cask 必须写 depends_on :macos。

  • 提交前在本地跑一次,能修的会自动改:

1
brew style --fix "$(brew --repo hanqunfeng/hello_cask)/Casks/hello-cask.rb"

【推荐】也可以用 brew create --cask URL --tap hanqunfeng/hello_cask --set-name hello-cask 生成模板。它会下载 zip、自动填 sha256,再打开编辑器,根据需要修改生成的模板文件。

提交 Cask 文件到 Github 仓库

  • 创建一个 Github 仓库:hanqunfeng/homebrew-hello_cask

1
2
3
4
5
cd "$(brew --repo hanqunfeng/hello_cask)"
git add .
git commit -m "Add hello-cask 1.0.0"
git remote add origin https://github.com/hanqunfeng/homebrew-hello_cask.git
git push -u origin main
  • 如果推送 .github/workflows/*.yml 失败,提示缺少 workflow 权限,给 PAT 加上 repo 和 workflow 后重推。具体处理和 brew -- 创建自己的 Formula 里一样:

1
2
git remote set-url origin https://ghp_xxxxx@github.com/hanqunfeng/homebrew-hello_cask.git
git push -u origin main

GitHub Actions 报错(brew test-bot)

  • brew tap-new 会生成 .github/workflows/tests.yml。推到 main 后会在 Ubuntu 和 macOS 上跑 brew test-bot --only-tap-syntax,核心是 brew style 检查脚本格式。

  • matrix 默认 fail-fast:Ubuntu 一失败,后面的 macos-26、macos-15-intel 会显示 canceled,那不是 macOS 自己坏了。

  • 常见 brew style 错误(HelloCask 实测会踩):

规则 问题 改法
Cask/Desc desc 以 A/An/The 开头 删掉冠词
Cask/Desc desc 里写了 macOS 不要提平台名
Homebrew/OSDependsOn 只有 .app 却没声明系统 加 depends_on :macos
Layout/EmptyLines 多空了一行 按 stanza 分组,组与组之间只空一行
Layout/TrailingEmptyLines 文件末尾缺少换行 文件必须以换行符结束,--fix 会自动补上
Cask/ArrayAlphabetization zap trash: 里只有一个元素还用了 [] 改成字符串,或没有残留就删掉 zap
  • 本地改完再推:

1
2
3
4
5
cd "$(brew --repo hanqunfeng/hello_cask)"
brew style --fix Casks/hello-cask.rb
git add Casks/hello-cask.rb
git commit -m "Fix brew style for hello-cask"
git push

安装 Cask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 添加 tap
brew tap hanqunfeng/hello_cask

# Homebrew 7 起,非官方 tap 默认不信任,安装前需要先声明信任
# 只信任这一个 cask
brew trust --cask hanqunfeng/hello_cask/hello-cask
# 或者信任整个 tap 里的所有 formula / cask
# brew trust --tap hanqunfeng/hello_cask

# 搜索
brew search hello-cask
## 输出
==> Casks
hanqunfeng/hello_cask/hello-cask

# 安装,--cask 可以省略(名字不冲突时),写上更明确
brew install --cask hello-cask
# 完整包名写法,避免和官方 cask 重名
# brew install --cask hanqunfeng/hello_cask/hello-cask
## 输出
==> Fetching downloads for: hello-cask
==> Installing Cask hello-cask
==> Moving App 'HelloCask.app' to '/Applications/HelloCask.app'
🍺 hello-cask was successfully installed!
  • 安装完成后:

    • .app 在 /Applications/HelloCask.app
    • 下载缓存和版本目录在 $(brew --caskroom)/hello-cask/1.0.0,Intel Mac 一般是 /usr/local/Caskroom/hello-cask/1.0.0

测试、卸载

1
2
3
4
5
6
7
8
9
10
11
# 查看信息
brew info --cask hello-cask

# 打开应用(或到 /Applications 里双击)
open -a HelloCask

# 普通卸载:只删掉 /Applications/HelloCask.app
brew uninstall --cask hello-cask

# --zap 会额外清理 zap 里列出的用户数据。本示例没有 zap,加了也没什么可删
# brew uninstall --cask --zap hello-cask

发布新版本

  • 应用打了新 tag、上传了新的 zip 之后,要改 tap 里的 Cask,再提交到 GitHub。别人 brew update 之后才会装到新版本。brew livecheck 只报告有没有新版本,不会改文件。brew create 只用于第一次生成,文件已经存在时不能拿来升级。

  • 可以手工改 Casks/hello-cask.rb。url 里已经有 #{version},把 version 和 sha256 换成新包的值即可。sha256 用 shasum -a 256 计算。

  • 也可以用命令改。--write-only 会下载新包、算出 sha256、写回本地 .rb,不会 fork,也不会开拉取请求。不加 --write-only 时,这条命令会按官方 homebrew/cask 的流程尝试开拉取请求。

  • 两种改法最后都要在 tap 仓库里提交并推送。

1
2
3
4
5
6
7
8
9
# 命令改文件
brew bump-cask-pr --write-only --version=1.0.1 hanqunfeng/hello_cask/hello-cask

# 看过 diff 后提交到 GitHub。手工改文件的话,从这里开始即可
cd "$(brew --repo hanqunfeng/hello_cask)"
git diff
git add Casks/hello-cask.rb
git commit -m "hello-cask 1.0.1"
git push

Cask 文件模板

  • Cask 不是 Formula 那种继承类,而是一个声明式 DSL:把“去哪下载、叫什么、装什么、怎么卸”写清楚,Homebrew 自己决定执行顺序。

  • Homebrew 把 cask do ... end 里的每一条配置声明叫做 stanza,这个词原意是诗的一节。version、sha256、url、name、app、binary、zap 各算一条,彼此独立。前面 brew style 要求「按 stanza 分组、组与组之间只空一行」,指的就是这些配置项。

1
2
3
cask "hello-cask" do
# 每一条 stanza(配置声明)都写在这个块里
end
  • 文件名、token、header 三者必须一致:Casks/hello-cask.rb ↔ cask "hello-cask" do

必填字段

1
2
3
4
5
6
7
8
version "1.0.0"
# 下载文件的 sha256;算不出来或 url 每次都变时可以用 :no_check,但能算就不要省
sha256 "..."

url "https://github.com/hanqunfeng/hello_cask/releases/download/v#{version}/HelloCask-#{version}.zip"
name "HelloCask" # 软件的正式名称,可含大小写、空格
desc "一句话描述,brew info 会显示"
homepage "https://github.com/hanqunfeng/hello_cask"
  • url 里用 #{version} 插值,以后升版本改 version 和 sha256 即可,不必重写整段地址。怎么改见前面示例里的「发布新版本」。

  • version :latest 必须搭配 sha256 :no_check,个人 tap 能打出版本号就不要用 :latest。

  • 另外至少要有一个 artifact(真正安装的东西)。最常见的是 app(GUI),也可以是 binary(命令文件),不必非有 .app。

app:安装 .app

1
2
3
4
5
6
7
8
9
# zip/dmg 解压后,根目录就是 HelloCask.app
app "HelloCask.app"
# 默认安装到 /Applications/HelloCask.app

# 如果解压后还套了一层目录
# app "HelloCask/HelloCask.app"

# 极少情况下才用 target: 改目标名称,例如避免冲突
# app "HelloCask.app", target: "Hello Cask Demo.app"

pkg:安装 .pkg

  • .pkg 走系统 installer,Cask 必须同时写 uninstall,否则用户卸不干净。

1
2
3
4
5
pkg "HelloCask.pkg"

uninstall pkgutil: "com.example.HelloCask"
# pkgutil: 的值是安装后的 package id,可在已安装机器上查:
# pkgutil --pkgs | grep -i hello
  • uninstall 常用键:

键 作用
pkgutil: 按 package id 卸载,.pkg 首选
quit: 按 bundle id 发送退出事件(相当于 Cmd+Q)
launchctl: 卸载 launchd 服务
delete: / trash: 按路径删除,trash: 进废纸篓,更安全
script: 跑官方卸载脚本
1
2
uninstall quit:    "com.example.HelloCask",
pkgutil: "com.example.HelloCask"

binary:把可执行文件链到 prefix/bin

  • Cask 的安装产物不必须是 .app / .dmg / .pkg。binary 可以把压缩包里的命令文件软链到 $(brew --prefix)/bin,装完就能在终端直接跑。Reasonix 整份脚本只有这一个 artifact,没有 .app。

  • 两种常见用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 压缩包里就是一个命令文件(Reasonix 这种)
binary "reasonix"
# 默认链到 $(brew --prefix)/bin/reasonix

# 2. GUI 应用附带命令行入口
app "HelloCask.app"
# target 指定命令名称
binary "#{appdir}/HelloCask.app/Contents/MacOS/HelloCask", target: "hello-cask"

# 3.同时要链接多个文件
# 把 redis-cli、redis-server 这些文件分别软链到 $(brew --prefix)/bin
binaries = [
"redis-cli",
"redis-benchmark",
"redis-check-aof",
"redis-check-rdb",
"redis-sentinel",
"redis-server",
]
binaries.each { |name| binary name }

zap:彻底清理

  • 普通 brew uninstall --cask 只移除 artifact(例如 /Applications 里的 .app)。用户数据、偏好设置、缓存要写在 zap 里,只有加 --zap 才会执行。

  • 不要凭 bundle id 猜路径。HelloCask 这种 osacompile 对话框应用,打开后通常也不会生成 ~/Library/Saved Application State/...。路径不存在时,zap 里写了也只是空操作。

  • 真实 GUI 应用常见残留大致在这些位置,以本机实际存在的为准:

1
2
3
4
5
6
zap trash: [
"~/Library/Application Support/HelloCask",
"~/Library/Preferences/com.example.HelloCask.plist",
"~/Library/Caches/com.example.HelloCask",
"~/Library/Saved Application State/com.example.HelloCask.savedState",
]
  • 正确做法是:先安装并打开一次应用,再自动扫描:

1
2
3
brew generate-zap hello-cask
# 或还没有 cask 文件时
brew generate-zap --name HelloCask
  • 扫不到额外文件时,官方习惯写一行注释:# No zap stanza required。HelloCask 演示就属于这种情况。

depends_on:依赖和系统约束

1
2
3
4
depends_on macos: :sonoma          # 最低 macOS
depends_on arch: :arm64 # 仅 Apple Silicon
depends_on formula: "ffmpeg" # 依赖某个 formula
depends_on cask: "macfuse" # 依赖另一个 cask

livecheck:检查上游新版本

  • brew livecheck --cask <token> 只把查到的版本号和脚本里的 version 比较,不下载、不安装。原理是:请求 url 的页面,用 regex 扫描全文,圆括号里捕获的那段才是版本号;匹配到多个时取得最大的一个。

  • 这个命令不走 JSON API,只读本机 tap 里的 .rb。官方 homebrew/cask 默认不在本地,直接查 claude-code 会报 These casks are not in any locally installed taps!。要先 brew tap homebrew/cask,并且脚本里写了 livecheck,命令才能按这段配置查出新版本。没写 livecheck 时,它会改去试 cask 的 url 和 homepage;地址如果只是某一个 zip,页面上没有版本列表,就查不出更新。Redis 的 cask 就是这样。skip 则明确不请求,文末 Reasonix 用的就是这种。

  • 调试:brew livecheck --debug --cask <token>。Formula 的写法见 brew -- 创建自己的 Formula。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# claude-code:这个地址的正文就是一行版本号,不是文件列表
# ^ 和 $ 表示整行必须是版本;v? 允许开头有 v,但不算进版本号
# 括号捕获 1.2.3。整页通常只有一个匹配,它就是当前 stable
livecheck do
url "https://downloads.claude.ai/claude-code-releases/stable"
regex(/^v?(\d+(?:\.\d+)+)$/i)
end

# 目录列表页:从链接文件名里取版本,多个匹配取最大的
# href="git-2.51.0.tar.gz" 里,括号捕获的是 2.51.0
livecheck do
url "https://mirrors.edge.kernel.org/pub/software/scm/git/"
regex(/href=.*?git[._-]v?(\d+(?:\.\d+)+)\.t/i)
end

# 不检查。字符串是跳过原因
livecheck do
skip "Auto-generated on release."
end

安装时操作文件

  • Cask 没有 Formula 的 def install。brew -- 创建自己的 Formula 里那些 Dir、File、Pathname、FileUtils 不能写在 cask do 顶层:顶层只是在登记 stanza,调用 chmod、cp 会报 undefined method。

  • 解压之后要改文件,写在 preflight_steps(artifact 安装之前)或 postflight_steps(artifact 安装之后)。卸载前后对应 uninstall_preflight_steps、uninstall_postflight_steps。

  • 这两个块在读取 cask 文件时只把步骤记下来,安装时才执行。块里只能调用下面这些步骤方法,不能写 Dir[...]、File.chmod、FileUtils.cp。

  • 相对路径默认相对本次解压目录。步骤块里没有 staged_path 这个方法,要写 {{staged_path}}、{{appdir}},安装时才替换。#{staged_path} 在这里会报错;它属于读文件时就算完的 Ruby 插值,不会留到安装时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 相对路径都相对 staged_path,它等于 $(brew --caskroom)/<token>/<version>,例如 .../Caskroom/hello-cask/1.0.0
preflight_steps do
# 相当于 Pathname#mkpath,父目录不存在也会创建
mkdir_p "deps-cache"
# 相当于 Pathname#write。append_newline: true 保证末尾有换行
write_file "VERSION", "1.0.0", append_newline: true

# 路径里的 * 在安装时展开,相当于 Dir[staged_path/"lib/*.sh"]
# 权限是字符串 "0755",不是 Ruby 的八进制数 0755
# recursive: false 只改匹配到的文件;省略时默认 chmod -R
set_permissions "lib/*.sh", "0755", recursive: false
# 也可以一次传入多个路径
set_permissions ["reasonix", "lib/*.sh"], "0755", recursive: false

# 相当于先 File.exist? 再决定要不要复制。条件在进入这个块时判断一次
if_path_exists "reasonix" do
copy "reasonix", "reasonix.bak"
end
# 相当于 FileUtils.rm_rf
remove "cache", recursive: true
end
  • 和 Formula 里那几类写法的对应关系:

Formula 的 install Cask 的 *_steps
Dir[path/"lib/*.sh"] 步骤参数里直接写 "lib/*.sh",安装时展开
(path/"dir").mkpath mkdir_p "dir"
(path/"file").write "..." write_file "file", "..."
chmod 0755, file set_permissions "file", "0755", recursive: false
cp src, dst copy "src", "dst"
rm_rf "cache" remove "cache", recursive: true
File.exist?(path) if_path_exists "path" do ... end
  • copy、move 要匹配恰好一个文件时,加 source_glob: true。remove、set_permissions、set_ownership 的路径列表会自动展开通配符。

  • 旧的 preflight do、postflight do、uninstall_preflight do、uninstall_postflight do 从 Homebrew 6.0.16(2026-08-10)起,官方 tap 跑 brew style 会被 Cask/InstallSteps 拒绝,6.0.15 还不会。官方 tap 的路径在 Taps/homebrew/homebrew-*,当时的报错是 Casks in official Homebrew taps must use postflight_steps instead of postflight(preflight_steps 等同理)。brew install 不跑 brew style,安装本身不会因此失败。同一版起,加载这些旧块会打废弃提示,代码仍会执行。

  • 7.0.0(2026-09-13)起,个人 tap 执行 brew style 也会报 Casks must use postflight_steps instead of postflight。这只在你运行 brew style 时出现,不阻止安装,所以第三方仓库可以继续发布旧写法。

  • Redis 官方 tap redis/redis 的 cask(当前 8.10.2)就是这样:仍使用 postflight do 和 uninstall_postflight do,里面用 FileUtils、Dir、File 复制 redis.conf、链接 bin 和模块。*_steps 不能写这种任意 Ruby。对本机 Casks/redis.rb 执行 brew style 能看到 Cask/InstallSteps,brew install --cask redis 仍会执行这些块。

  • 官方仍推荐 *_steps,就是为了安装时不执行 Cask 文件里的任意 Ruby。Homebrew 4.0 起,官方 homebrew/cask 默认不在本机执行那个 .rb,安装用的是 formulae.brew.sh 上预先生成并签名的 JSON。mkdir_p、copy、set_permissions、run 是固定动作,参数是字面量,能原样放进 JSON,安装时在沙箱里按清单执行。FileUtils、Dir、if、循环写不进这份清单,不执行就看不出这段 Ruby 会做什么,所以官方包不能把可执行代码放进安装流程,brew style 直接拒绝。

  • redis/redis 不在官方 tap 里,安装时仍会加载本地 Casks/redis.rb 并执行 postflight do,所以还能用旧写法。这种写法进不了 homebrew/cask。

  • 能用步骤表达的逻辑写 *_steps。必须写任意 Ruby 时,个人 tap 仍可使用旧块,staged_path 在安装时是真实的 Pathname。提交到官方 homebrew/cask 会被 brew style 拒绝。

1
2
3
4
# 个人 tap 里仍可安装;brew style 会报 Cask/InstallSteps
postflight do
Dir[staged_path/"lib/*.sh"].each { |f| FileUtils.chmod 0755, f }
end

按系统和架构提供不同下载地址

  • 很多应用按操作系统和 CPU 各提供一个包。arch、os、sha256 的键都是 Homebrew 规定的,不能改名。安装时只看当前这一台机器:用选中的字符串拼 url,再用对应的那一个 sha256 比对,另外三组不参与这次安装。

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
cask "hello-cask" do
# 键固定:arch 只能是 arm: / intel:,os 只能是 macos: / linux:
# 值自己定,分别填进 #{arch}、#{os};Homebrew 不检查这些字符串,但要和上游文件名一致
arch arm: "arm64", intel: "amd64"
os macos: "darwin", linux: "linux"

version "1.0.0"
# 键也是固定的四个,右边必须是对应文件的真实校验值
# 当前机器只取其中一行:
# macOS Apple Silicon → arm + #{os}=darwin + #{arch}=arm64
# macOS Intel → intel + #{os}=darwin + #{arch}=amd64
# Linux ARM64 → arm64_linux + #{os}=linux + #{arch}=arm64
# Linux x86_64 → x86_64_linux + #{os}=linux + #{arch}=amd64
sha256 arm: "7c70ba2f...",
intel: "ebf5da3a...",
arm64_linux: "0408b76a...",
x86_64_linux: "a8ae1baf..."

# Apple Silicon 的 Mac 实际下载 HelloCask-darwin-arm64.zip,并和 sha256 arm: 比对
url "https://github.com/hanqunfeng/hello_cask/releases/download/v#{version}/HelloCask-#{os}-#{arch}.zip"
name "HelloCask"
desc "Demo"
homepage "https://example.com"

app "HelloCask.app"
end
  • 同样的区分也可以写成 on_macos、on_linux、on_arm、on_intel:安装时只执行匹配当前机器的那一支,里面直接写该平台的 url 和 sha256。文末 Reasonix 的下载地址就是这样写的。

  • on_* 是条件块,分支里可以放任意 stanza(上面说的那些配置声明),因此还能表达更复杂的差异:各架构 version 不同、安装产物不同、按 macOS 版本(如 on_sonoma、on_tahoe)分叉,或某段安装后逻辑只在一个系统上执行。只是文件名和校验值不同时,用上面的 arch / os 更短。

token 命名要点

  • 个人 tap:文件名去掉 .rb 必须等于 cask "..." 里的字符串,用户安装时也敲这个名字。这是硬约束。brew generate-cask-token 只是帮你起名,可以不用。

  • 若向官方 homebrew/cask 投稿,才建议用它生成 token,规则是:取 .app 名字 → 去掉 .app → 小写 → 空格变 -。它不会拆驼峰:HelloCask → hellocask,Hello Cask → hello-cask。

  • 和已有 formula / cask 重名时,加厂商前缀或 -app 后缀,例如 formula 叫 unison,cask 叫 unison-app。

  • 固定大版本用 corretto@11;测试通道用 google-chrome@beta。@latest 表示上游的发布通道,和 version :latest 不是一回事。

第三方 tap 的本地修改会生效

  • Homebrew 4.0 之后,官方 homebrew/cask 默认走 JSON API,改本地 .rb 不会参与安装,需要 HOMEBREW_NO_INSTALL_FROM_API=1。

  • 自己的 tap 不受这个限制:安装时本来就会 clone 到 Taps 目录,改完 Casks/hello-cask.rb 再 brew reinstall --cask hello-cask 就会用新脚本。

1
2
brew edit --cask hanqunfeng/hello_cask/hello-cask
brew reinstall --cask hanqunfeng/hello_cask/hello-cask

以 Reasonix 为例,说明脚本配置项

  • 这是一个只有命令文件、没有 .app 的 Cask:按系统/架构下载对应 tar.gz,用 binary "reasonix" 链到 bin。说明 Cask 完全可以用来分发预编译 CLI,本机只会走其中一条 on_* 分支。

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
# token:文件名、header、安装名必须一致,用户执行 brew install --cask reasonix
cask "reasonix" do

# 安装完成、artifact 链好之后执行。能写哪些步骤见上面「安装时操作文件」
postflight_steps do
on_macos do # 仅 macOS 执行;Linux 没有 quarantine 属性
# run 只启动一个程序,不会走 shell 字符串
# xattr -d 删除属性,-r 递归
# com.apple.quarantine:从网上下载的文件会被打上隔离标记,Gatekeeper 可能拦执行
# {{staged_path}} 是安装时才展开的路径(不是 Ruby 的 #{...}),即本次解压目录
run "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "{{staged_path}}/reasonix"]
end
end

version "1.38.11" # 软件版本;下面 url 里的 #{version} 都用它拼接

# 按 操作系统 × CPU 选择下载包
on_macos do
on_arm do # Apple Silicon
sha256 "7c70ba2f363bfa01a709975062b9fd6bd0ac82b8a0209ddb988277dbfbef7d07" # 该文件校验值,对不上就中止
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-darwin-arm64.tar.gz"
end
on_intel do # Intel Mac
sha256 "ebf5da3adcf8cce2e3c559718e5c853b2eb6a6793fc42a88ce0b1ca8dc1b09e7"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-darwin-amd64.tar.gz"
end
end
on_linux do
on_arm do # Linux ARM
sha256 "0408b76a32f75f72387d07cc55b27dafd666f3bfe3be0424c3f55c11f73b8267"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-linux-arm64.tar.gz"
end
on_intel do # Linux x86_64
sha256 "a8ae1baf4c81eaa5d8a455d1336e9c08328f3fcd176b25129902d787a19f821a"
url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-linux-amd64.tar.gz"
end
end

name "reasonix" # 软件正式名称,给搜索、brew info 用,可与 token 不同
desc "Cache-first DeepSeek coding agent for the terminal." # 一句话介绍
homepage "https://github.com/esengine/DeepSeek-Reasonix" # 项目主页,brew home reasonix 会打开

livecheck do
# 跳过检查,不请求上游。写法见上面「livecheck」
skip "Auto-generated on release."
end

# 真正安装的产物:解压目录里名为 reasonix 的可执行文件
# 会软链到 $(brew --prefix)/bin/reasonix,装完可直接在终端运行
binary "reasonix"
end

以 Redis 为例,说明旧的 postflight

  • 个人和第三方 tap 安装时仍会执行 .rb,所以旧的 postflight do、uninstall_postflight do 还能用。下面是 redis/homebrew-redis 8.10.2 的脚本。brew style 会报 Cask/InstallSteps,brew install 仍会执行这些块。官方 homebrew/cask 不能这样写。

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
# token、文件名、安装名都是 redis
cask "redis" do
# 当前 CPU 对应的字符串,后面 #{arch} 用它。arm 是 Apple Silicon,intel 是 Intel Mac
arch arm: "arm64", intel: "x86_64"

version "8.10.2" # 版本号,url 里的 #{version} 用它
# 两个包各一个校验值。安装时只比对当前架构那一个,对不上就中止
sha256 arm: "58023ce2a4c7a5c2b1e2f46c443c4ac160b207fe11d5e4f846ab995ca392e436",
intel: "08334ca85583bc81323be65d22455b0d36d21c3e149c588c911f80284cd8c6b4"

# 读取 cask 时就把 version、arch 拼进下载地址
url "https://packages.redis.io/homebrew/redis-oss-#{version}-#{arch}.zip"
name "Redis Open Source" # 给人看的名称,可以和 token 不同
# brew info 里的一句话。官方 tap 会嫌它太长,并且不应以 cask 名开头
desc "Redis is an in-memory database that persists on disk. The data model is key-value, but many different kind of values are supported: Strings, Lists, Sets, Sorted Sets, Hashes, Streams, HyperLogLogs, Bitmaps."
homepage "https://redis.io/" # brew home redis 打开这个地址

depends_on macos: :sonoma # 最低系统是 macOS Sonoma

depends_on formula: "openssl@3" # 安装本 cask 之前先安装这些 formula
depends_on formula: "libomp"
depends_on formula: "llvm@18"

# 普通 Ruby 数组,只在下面的 postflight 里使用,不是 stanza
binaries = %w[
redis-cli
redis-benchmark
redis-check-aof
redis-check-rdb
redis-sentinel
redis-server
]

# 旧语法:解压并处理完 artifact 之后执行。块里可以写任意 Ruby
postflight do
basepath = HOMEBREW_PREFIX.to_s # 例如 /usr/local 或 /opt/homebrew
caskbase = "#{caskroom_path}/#{version}" # 本次解压目录,也就是 staged_path
confdir = "#{basepath}/etc" # 配置要放到 prefix/etc
moduledir = "#{basepath}/lib/redis/modules" # 模块要放到 prefix 下这个目录

FileUtils.mkdir_p(confdir) # 相当于 mkdir -p,父目录不存在也会创建
FileUtils.mkdir_p(moduledir)

# 包里的 redis.conf 含占位符 <HOMEBREW_PREFIX>,复制到 prefix/etc 再替换
src = "#{caskbase}/etc/redis.conf" # 解压目录里的原配置
conffile = "#{confdir}/redis.conf" # 复制后的目标
FileUtils.cp(src, conffile) unless File.exist?(conffile) # 用户已经改过的配置不覆盖
text = File.read(conffile) # 读出全文
new_contents = text.gsub("<HOMEBREW_PREFIX>", basepath) # 把占位符换成实际前缀
File.open(conffile, "w") { |file| file.puts new_contents } # 写回;puts 会再补一个换行

# 把每个命令软链到 prefix/bin。ln_sf:目标已存在就替换
binaries.each do |item|
src = "#{caskbase}/bin/#{item}"
dest = "#{basepath}/bin/#{item}"
FileUtils.ln_sf(src, dest)
end

# Dir[通配符] 列出解压目录里的 .so。已有同名文件或链接就跳过
Dir["#{caskbase}/lib/redis/modules/*.so"].each do |item|
module_name = File.basename(item) # 只取文件名
dest = "#{moduledir}/#{module_name}"
File.symlink(item, dest) unless File.exist?(dest)
end
end

# 卸载完成之后执行,删掉 postflight 建的链接;配置文件 redis.conf 留着
uninstall_postflight do
basepath = HOMEBREW_PREFIX.to_s

# 只删除“是软链接”的命令,避免误删同名的真实文件
binaries.each do |item|
dest = "#{basepath}/bin/#{item}"
File.delete(dest) if File.symlink?(dest) && File.exist?(dest)
end

# 删掉模块目录里的每个 .so 链接
moduledir = "#{basepath}/lib/redis/modules"
Dir["#{moduledir}/*.so"].each do |item|
module_name = File.basename(item)
dest = "#{moduledir}/#{module_name}"
File.delete(dest)
end

# 目录已经空了才删除,里面还有别的文件就保留
FileUtils.rm_rf(moduledir) if Dir.empty?(moduledir)
FileUtils.rm_rf("#{basepath}/lib/redis") if Dir.empty?("#{basepath}/lib/redis")
end

# 安装结束时打印。<<~EOS 会去掉公共前导缩进
caveats <<~EOS
Redis Open Source has been successfully installed!

The default configuration file has been copied to:
#{HOMEBREW_PREFIX}/etc/redis.conf

To customize Redis, edit this file as needed and restart Redis to apply changes.

If you want to run Redis as a service, use:
redis-server #{HOMEBREW_PREFIX}/etc/redis.conf

To stop the service:
redis-cli shutdown
EOS
end

后记