My Homebrew tap is five formulas and one command. It has existed since February 2025, it contains no interesting code, and until yesterday it worked the way taps usually work. The thing worth writing about is not the tap. It is which direction the data moves.
Meu tap do Homebrew são cinco formulas e um comando. Ele existe desde fevereiro de 2025, não tem nenhum código interessante dentro, e até ontem funcionava do jeito que taps normalmente funcionam. O que vale escrever não é o tap. É em que direção os dados se movem.
brew install youhide/tap/hidepass brew install youhide/tap/chainchaos brew install youhide/tap/hidedot brew install youhide/tap/hidetop brew install youhide/tap/oxinit # Linux only
What pushing costs
O preço de empurrar
The normal arrangement is that each project pushes. A project cuts a release, and the same workflow that built the binaries then commits an updated formula into the tap. To write a commit into another repository, that workflow needs a token with write access to it — and the token Actions hands you is scoped to the repository it runs in, so this means a personal access token, stored as a secret, in every project that ships through the tap.
O arranjo normal é que cada projeto empurra. O projeto corta um release, e o mesmo workflow que compilou os binários faz um commit com a formula atualizada dentro do tap. Para escrever um commit em outro repositório, esse workflow precisa de um token com acesso de escrita a ele — e o token que o Actions te entrega é escopado ao repositório onde roda, então isso significa um personal access token, guardado como secret, em todo projeto que distribui pelo tap.
Five projects, five copies of one credential, and what that credential can do is rewrite the thing that installs software on other people's machines. None of this is unusual — it is how most taps are wired. It just stopped sitting well with me that a leaked secret in a side project had "replace the binary everyone brew-installs" inside its blast radius.
Cinco projetos, cinco cópias de uma credencial, e o que essa credencial pode fazer é reescrever a coisa que instala software na máquina de outras pessoas. Nada disso é incomum — é como a maioria dos taps é montada. Só parou de me descer bem que um secret vazado num projeto paralelo tivesse "trocar o binário que todo mundo instala pelo brew" dentro do seu raio de dano.
So the tap pulls instead
Então o tap puxa
Now nothing pushes. The tap goes and looks: a workflow wakes up every hour, asks GitHub for the latest release of each project, and regenerates the formulas from what it finds.
Agora nada empurra. O tap vai olhar: um workflow acorda a cada hora, pergunta ao GitHub qual é o último release de cada projeto, e regenera as formulas a partir do que encontrar.
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
push:
branches: [main]
paths:
- projects.toml
- scripts/update.py
- .github/workflows/update.yml
permissions:
contents: write
That permissions block is the whole argument. It grants write access to
its own repository, using the token Actions already provides. There is no secret to
leak, because there is no secret. The projects, meanwhile, lost a step each: the commit in
hidePass that did it is called "Let the Homebrew tap pick up releases on its own",
which is also an accurate description of the entire design.
Esse bloco permissions é o argumento inteiro. Ele dá acesso de
escrita ao próprio repositório, usando o token que o Actions já fornece. Não existe
secret pra vazar, porque não existe secret. Os projetos, por sua vez, perderam um passo cada:
o commit no hidePass que fez isso se chama "Let the Homebrew tap pick up releases on its
own", que também é uma descrição exata do design inteiro.
A release shows up within the hour. Nothing that builds it needs
permission to touch the thing that ships it.
Um release aparece dentro de uma hora. Nada que o compila precisa de
permissão para encostar na coisa que o distribui.
One table per tool
Uma tabela por ferramenta
The entire interface is a TOML file. Adding a tool to the tap is adding a table to it, and the projects themselves are not edited at all:
A interface inteira é um arquivo TOML. Adicionar uma ferramenta ao tap é adicionar uma tabela nele, e os projetos em si não são editados em nada:
[oxinit]
repo = "youhide/oxinit"
desc = "Service manager and PID 1 for Linux"
license = ["MIT", "Apache-2.0"]
binaries = ["oxinit", "oxctl", "oxlogd"]
docs = ["UNIT_FORMAT.md"]
prereleases = true
[oxinit.assets]
linux_arm = "oxinit-{tag}-aarch64-linux-musl.tar.gz"
linux_intel = "oxinit-{tag}-x86_64-linux-musl.tar.gz"
A list in license means "any of", which is how a dual MIT-or-Apache
crate gets described honestly. The first entry in binaries is the one
brew test runs. Projects that use GoReleaser's default archive names skip the
asset table entirely and give a prefix instead, since the names are then derivable.
Uma lista em license significa "qualquer uma das", que é como um
crate com licença dupla MIT-ou-Apache é descrito honestamente. A primeira entrada de
binaries é a que o brew test executa. Projetos que usam os nomes de
arquivo padrão do GoReleaser dispensam a tabela de assets e dão um prefixo, já que os nomes
passam a ser deriváveis.
The gate is the absence of a key
O gate é a ausência de uma chave
oxinit is Linux-only, and nothing in that table says so. It is Linux-only because
there is no darwin_arm and no darwin_intel key — the platforms a
formula offers are exactly the platforms it was told where to find.
O oxinit é Linux-only, e nada naquela tabela diz isso. Ele é Linux-only porque
não existe chave darwin_arm nem darwin_intel — as plataformas que uma
formula oferece são exatamente as plataformas para as quais alguém disse onde procurar.
I like that more than a linux_only = true flag, because you cannot
forget to set it. The only way to offer macOS is to say where the macOS archive is, so the
declaration and the capability cannot drift apart. The same table also carries
prereleases = true, which oxinit needs for a dull and specific reason: its only
release so far is marked as a prerelease, and GitHub's "latest release" does not include
those.
Eu prefiro isso a uma flag linux_only = true, porque não tem como
esquecer de marcar. O único jeito de oferecer macOS é dizer onde está o arquivo de macOS, então
a declaração e a capacidade não conseguem se descolar. A mesma tabela também carrega
prereleases = true, de que o oxinit precisa por um motivo chato e específico: o
único release dele até agora está marcado como prerelease, e o "latest release" do GitHub não
inclui esses.
Formula/ is build output
Formula/ é saída de build
Both the formulas and the README's list of them are generated by the same 229-line
Python script, which is why the repository says not to edit Formula/ by hand. A
formula is not source here; it is a rendering of a release that already exists.
Tanto as formulas quanto a lista delas no README são geradas pelo mesmo script
Python de 229 linhas, e é por isso que o repositório diz para não editar
Formula/ na mão. Uma formula não é fonte aqui; ela é a renderização de um release
que já existe.
Checksums come from the release's own checksums.txt or
<asset>.sha256 when the project publishes them, and the script downloads the
archive and hashes it only when neither exists. Preferring the published one is deliberate: it
was written by the workflow that built the binary, next to the binary, rather than recomputed
later by something that merely fetched it.
Os checksums vêm do checksums.txt ou dos
<asset>.sha256 do próprio release quando o projeto os publica, e o script só
baixa o arquivo e calcula o hash quando nenhum dos dois existe. Preferir o publicado é de
propósito: ele foi escrito pelo workflow que compilou o binário, ao lado do binário, em vez de
recalculado depois por algo que só o baixou.
What it does when something breaks
O que ele faz quando algo quebra
An hourly job that rewrites a distribution channel has to be boring on its bad days, so three of its decisions are about failure rather than success:
Um job que roda de hora em hora reescrevendo um canal de distribuição tem que ser chato nos dias ruins, então três das suas decisões são sobre falha, não sobre sucesso:
- The commit step runs
if: !cancelled(), on purpose — one project with a broken release fails its own formula and does not hold back the four that are fine. - It checks
git diff --cached --quietbefore committing, so an hour in which nothing was released produces no commit at all rather than an empty one. - A
concurrencygroup means the hourly run and an impatient manual run cannot both be rewritingFormula/at the same time.
- O passo de commit roda com
if: !cancelled(), de propósito — um projeto com release quebrado falha a própria formula e não segura as outras quatro que estão bem. - Ele checa
git diff --cached --quietantes de commitar, então uma hora em que nada foi lançado não produz commit nenhum, em vez de um commit vazio. - Um grupo de
concurrencyfaz com que a execução de hora em hora e uma execução manual impaciente não possam estar reescrevendo oFormula/ao mesmo tempo.
What I gave up is immediacy: a release is installable within the hour instead of
within the minute. For a tap that distributes my own command-line tools, an hour is not a cost
I can bring myself to care about — and workflow_dispatch is there for the days I
do.
Abri mão de imediatismo: um release fica instalável dentro de uma hora em vez de
dentro de um minuto. Para um tap que distribui as minhas próprias ferramentas de
linha de comando, uma hora não é um custo com que eu consiga me importar — e o
workflow_dispatch está ali para os dias em que eu me importe.
The repository is at github.com/youhide/homebrew-tap. There is almost nothing in it, which after this change is the correct amount.
O repositório está em github.com/youhide/homebrew-tap. Não tem quase nada lá dentro, o que depois dessa mudança é a quantidade certa.