メインコンテンツまでスキップ

zig v0.13.0で作成したバイナリをHomebrew経由でインストールできるようにするためにGitHub ActionでCI/CDを構築した

zig言語で作ったzagというライブラリをHomebrewでインストールできるようにするまでの物語だ。 成果物としてのリポジトリは2つ。本記事ではこの2つのリポジトリのCI/CD周りについて説明してく。

リポジトリ名中身
Himenon/zagアプリケーション本体
Himenon/homebrew-zagHomebrewのTap

※ HomebrewのTapについてはHomebrewの用語を挙動を確認しながら理解するで紹介しているのでそちらを見てね。

ライブラリの実装

大したことしてません。zag!とprintするだけのコードです。

https://github.com/Himenon/zag/blob/v0.0.9/src/main.zig

const std = @import("std");

pub fn main() !void {
const stdout_file = std.io.getStdOut().writer();
var bw = std.io.bufferedWriter(stdout_file);
const stdout = bw.writer();
try stdout.print("zag!\n", .{});
try bw.flush(); // don't forget to flush!
}

ライブラリ側のCI/CDにおけるビルドとリリース

zigでビルドして、クロスプラットフォーム向けのbinaryを作成するためのCI/CDを目指します。 やるべきこととしては、

  1. v*.*.*のタグが作成されたことをトリガーにGitHub Actionを実行する。
  2. 配布したいOSでzig buildを実施する。
  3. 配布する成果物(binary等)をGitHub ReleaseのArtifactsにzag-{platform}-${cpu.arch}.zipの命名規則でアップロードする。

となります。このときのGitHub Actionは以下のようになります。

name: Release Workflow

on:
push:
tags:
- "v*.*.*"

jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
with:
ref: main
- uses: ./.github/actions/initialize
- uses: mlugg/setup-zig@v1
with:
version: 0.13.0
- name: Zig Build and Test
# node -e "console.log(`\${process.platform}-\${process.arch}`)"
run: |
zig version
zig build
env:
TAG_NAME: ${{ github.ref_name }}

- name: Archive Build Artifact
run: |
./packing.sh # 後述

- name: Upload Release Asset
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
name: ${{ github.event.pull_request.body }}
tag_name: ${{ github.event.pull_request.title }}
generate_release_notes: true
files: artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

packing.sh以外についてはGitHub Action

zag-{platform}-${cpu.arch}.zip`の名前をどこでどうやって生成するか

Artifactsにアップロードするzip圧縮ファイルの命名について、まだ改善の余地があります。 zag@0.0.9においては、build.zigzig buildで実行されるファイル)内でpacking.shファイルを生成しています。

fn generate_zip_script(b: *std.Build, platform: []const u8, arch: []const u8) []const u8 {
const zip_file_name = std.fmt.allocPrint(b.allocator, "zag-{s}-{s}.zip", .{ platform, arch }) catch unreachable;

return std.fmt.allocPrint(b.allocator,
\\#!/bin/sh
\\
\\mkdir -p artifacts
\\zip -r {s} ./zig-out/bin/
\\mv {s} artifacts/
\\
, .{ zip_file_name, zip_file_name }) catch unreachable;
}

pub fn build(b: *std.Build) void {
// 省略

const arch = @tagName(target.result.cpu.arch);
const platform_name = switch (target.result.os.tag) {
.macos => "darwin",
.linux => "linux",
.windows => "win32",
else => @tagName(target.result.os.tag),
};
// @tagNameを利用するとenumがその宣言名になって返ってくる
std.debug.print("Building for arch: {s}, platform: {s}\n", .{
@tagName(target.result.cpu.arch),
platform_name,
});

const script_content = generate_zip_script(b, platform_name, arch);

var file = std.fs.cwd().createFile("./packing.sh", .{ .mode = 0o755 }) catch unreachable;
defer file.close();
var writer = file.writer();
writer.writeAll(script_content) catch {};

// 省略
}

packing.shが必要な理由はtarget.result.os.tagがMacOSの場合はdarwinではなくmacosを返すため、他の言語のplatformの返り値と結果が合いません。 例えば、Nodejsを利用している場合は次のような結果を得ます。

$ node -e "console.log(`\${process.platform}-\${process.arch}`)"
# => darwin-arm64

まだzigが開発中ということも有り今後は変わるかもしれませんが、他の言語と足並みを揃える目的で変換をしています。

const platform_name = switch (target.result.os.tag) {
.macos => "darwin",
.linux => "linux",
.windows => "win32",
else => @tagName(target.result.os.tag),
};

参考にした事例としてはoven-sh/bunのバイナリがこの形式で命名しています。

Homebrewでアプリケーションをインストールできるようにする

基本的にHomebrewの仕組みに則るだけです。Formulaを作成して配置するだけです。 ただし、めんどうなこともやはりあって、Formulaファイルはfetch対象のzipファイルのchecksumを要求することで、これをバージョンアップのたびに更新する必要があります。

v0.0.9のFormulaのコード

class ZagAT009 < Formula
desc "This library just want to call a library made with Zig lang “Zag.”"
homepage "https://github.com/Himenon/zag"
license "MIT"
version "0.0.9"

livecheck do
url "https://github.com/Himenon/zag/releases/latest"
regex(%r{href=.*?/tag/zag-v?(\d+(?:\.\d+)+)["' >]}i)
end

if OS.mac?
if Hardware::CPU.arm? || Hardware::CPU.in_rosetta2?
url "https://github.com/Himenon/zag/releases/download/v#{version}/zag-darwin-aarch64.zip"
sha256 "f2c6d931f9fffd0692812ac23b7c00149247385def70c5061858467c464897e5" # zag-darwin-aarch64.zip
end
elsif OS.linux?
if Hardware::CPU.arm?
url "https://github.com/Himenon/zag/releases/download/v#{version}/zag-linux-x86_64.zip"
sha256 "ef2a1dcb5659d4ec6d603d396962092208a6cb6031ae1018cc4612537abc2f6d" # zag-linux-x86_64.zip
end
else
odie "Unsupported platform. Please submit a bug report here: https://zag.sh/issues\n#{OS.report}"
end

def install
bin.install "bin/zag"
ENV["zag_INSTALL"] = "#{bin}"
end

def test
assert_match "#{version}", shell_output("#{bin}/zag -v")
end
end

homebrew-zagをどう管理するか

これもoven-sh/homebrew-bunを参考に仕組みを作っていきます。 bunの構成をそのままに、zag(自作ライブラリ)向けのFormulaのディレクトリ・ファイル構成は次のようになります。

homebrew-zag/
├── Formula
│ ├── zag.rb # 最新バージョン homebrew install himenon/zag/zag はここを見る
│ ├── zag@0.0.5.rb
│ ├── zag@0.0.8.rb
│ └── zag@0.0.9.rb # 特定のバージョン homebrew install himenon/zag/zag@0.0.9
├── LICENSE
├── README.md
└── scripts
└── release.mjs # Formulaの作成・更新等

bunの方はscripts/release.rbでRubyで書かれていますが、自分が管理しやすいようにJavaScriptで書き直しました。 Formula/*はhomebrewでインストールするときに指定するバージョンに対応する インストール方法を記述したFormulaを列挙するようにします。 これによって各バージョンごとのインストール方法を独立することができます。

scripts/release.mjsの中身

release.mjsの責務は新しいバージョンのFormulaファイルを作成することです。 実行方法は次のようになります。

node scripts/release.mjs 0.0.9

自動化されて嬉しいのはリリースのAsset(https://github.com/Himenon/zag/releases/tag/v0.0.9)からzipファイルをダンロードして、sha256を計算するところです。 この作業を手作業でやると、このスクリプトのありがたみが痛いほどわかるので必ず配置しておくべきと強く言えます。 ここまでやってしまえばhomebrewでインストールできる状態を構築できます。

import fs from "node:fs";
import crypto from "node:crypto";

const versionArg = process.argv[2];

if (!versionArg) {
console.error("Usage: release.ts [x.y.z]");
process.exit(1);
}

const version = versionArg.replace(/[a-z-]*/gi, "");

console.log(`Releasing zag on Homebrew: v${version}`);

const url = `https://api.github.com/repos/Himenon/zag/releases/tags/v${version}`;

/**
*
* @param {string} url
* @returns {Promise<{ assets: { name: string; browser_download_url: string; }[] }>}
*/
const fetchGitHubRelease = async (url) => {
const res = await fetch(url);
return res.json();
};

/**
*
* @param {string} url
* @returns
*/
const fetchAsset = async (url) => {
const res = await fetch(url);
const value = await res.arrayBuffer();
const data = Buffer.from(value);
return { data, finalUrl: url };
};

async function main() {
try {
const release = await fetchGitHubRelease(url);
console.log(`- Found release: ${release.name}`);
console.log(`- Fetched Url: ${url}`);

/**
* @type Record<string, string>
*/
const assets = {};

const tasks = release.assets.map(async (asset) => {
const filename = asset.name;
if (!filename.endsWith(".zip") || filename.includes("-profile")) {
console.log(`- Skipped asset: ${filename}`);
return;
}

const { data } = await fetchAsset(asset.browser_download_url);
const sha256 = crypto.createHash("sha256").update(data).digest("hex");

console.log(`- Found asset: ${filename} [sha256: ${sha256}]`);
assets[filename] = sha256;
});

await Promise.all(tasks);

let formula = fs.readFileSync("Formula/zag.rb", "utf8");
formula = formula
.split("\n")
.map((line) => {
const query = line.trim();
if (query.startsWith("version")) {
return line.replace(/"[0-9.]+"/, `"${version}"`);
}

if (query.startsWith("sha256")) {
const asset = query.split("#").at(1)?.trim();
if (!asset || !assets[asset]) {
throw new Error(`Did not find sha256: ${asset}`);
}
return line.replace(/"[A-Fa-f0-9]+"/, `"${assets[asset]}"`);
}
return line;
})
.join("\n");

const versionedClass = `class ZagAT${version.replace(/\./g, "")}`;
const versionedFormula = formula.replace(/class Zag/, versionedClass);

fs.writeFileSync(`Formula/zag@${version}.rb`, versionedFormula);
console.log(`- Saved Formula/zag@${version}.rb`);

fs.writeFileSync("Formula/zag.rb", formula);
console.log("- Saved Formula/zag.rb");

let readme = fs.readFileSync("README.md", "utf8");
readme = readme.replace(/zag@[0-9]+\.[0-9]+\.[0-9]+/, `zag@${version}`);
fs.writeFileSync("README.md", readme);
console.log("- Update README.md");

console.log("Done");
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}

await main().then((error) => {
process.exit(1);
});

さいごに

すっ飛ばしてここを読んでいる人はぜひ手を動かしてやってみてください。 zigで作成したアプリケーションをhomebrewでインストールできるところまで、 というのは簡単そうに見えて以外にも色んな知識が必要でした。

使っているけど知らなかったことが出てくるのでぜひやってみてください。