zig v0.13.0で作成したバイナリをHomebrew経由でインストールできるようにするためにGitHub ActionでCI/CDを構築した
zig
言語で作ったzag
というライブラリをHomebrewでインストールできるようにするまでの物語だ。
成果物としてのリポジトリは2つ。本記事ではこの2つのリポジトリのCI/CD周りについて説明してく。
リポジトリ名 | 中身 |
---|---|
Himenon/zag | アプリケーション本体 |
Himenon/homebrew-zag | Homebrewの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! }
zigでビルドして、クロスプラットフォーム向けのbinaryを作成するためのCI/CDを目指します。 やるべきこととしては、
v*.*.*
のタグが作成されたことをトリガーにGitHub Actionを実行する。zig build
を実施する。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
Artifactsにアップロードするzip圧縮ファイルの命名について、まだ改善の余地があります。
zag@0.0.9
においては、build.zig
(zig 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の仕組みに則るだけです。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
これも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を列挙するようにします。
これによって各バージョンごとのインストール方法を独立することができます。
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でインストールできるところまで、 というのは簡単そうに見えて以外にも色んな知識が必要でした。
使っているけど知らなかったことが出てくるのでぜひやってみてください。