Homebrew bottle builder and tap manager for OCaml monorepos
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial release: homebrew bottle builder and tap manager

- YAML configuration for binary definitions and storage settings
- Platform-aware bottle building (macOS ARM64/Intel, Linux)
- S3-compatible storage upload via rclone
- Automatic Ruby formula generation with head-build support
- SHA256 checksum computation and formula updating
- Full release workflow: build, upload, update tap
- CLI with subcommands: build, upload, formula, release, config

+1512
+17
.gitignore
··· 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Dune package management 7 + dune.lock/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/
+1
.ocamlformat
··· 1 + version=0.28.1
+15
LICENSE.md
··· 1 + ISC License 2 + 3 + Copyright (c) 2025 Thomas Gazagnaire 4 + 5 + Permission to use, copy, modify, and/or distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 + PERFORMANCE OF THIS SOFTWARE.
+115
README.md
··· 1 + # homebrew 2 + 3 + Homebrew bottle builder and tap manager for OCaml monorepos. 4 + 5 + ## Overview 6 + 7 + Build, upload, and release Homebrew bottles from dune monorepos. Generates 8 + Ruby formulas with platform-specific SHA256 checksums, uploads bottles to 9 + S3-compatible storage, and manages tap repositories. 10 + 11 + ## Features 12 + 13 + - YAML configuration for binary definitions and storage settings 14 + - Platform-aware bottle building (macOS ARM64/Intel, Linux x86_64/ARM64) 15 + - S3-compatible storage upload via rclone (dated + rolling "latest" releases) 16 + - Automatic Ruby formula generation with head-build support 17 + - SHA256 checksum computation and formula updating 18 + - Full release workflow: build, upload, update tap, commit and push 19 + 20 + ## Installation 21 + 22 + ``` 23 + opam install homebrew 24 + ``` 25 + 26 + ## Configuration 27 + 28 + Create a `homebrew.yml` file: 29 + 30 + ```yaml 31 + handle: gazagnaire.org 32 + 33 + storage: 34 + bucket: homebrew-bottles 35 + region: fr-par 36 + profile: homebrew-monopam 37 + 38 + tap: 39 + clone_url: https://tangled.org/gazagnaire.org/homebrew-monopam.git 40 + push_url: git@git.recoil.org:gazagnaire.org/homebrew-monopam 41 + 42 + binaries: 43 + - name: prune 44 + package: prune 45 + description: "Dead code remover for OCaml .mli files" 46 + homepage: https://tangled.org/gazagnaire.org/prune 47 + 48 + - name: merlint 49 + package: merlint 50 + description: "Opinionated OCaml linter powered by Merlin" 51 + 52 + - name: agent 53 + package: ocaml-agent 54 + description: "Claude Code container orchestrator" 55 + exe_name: main 56 + head_deps: 57 + - name: docker 58 + type: recommended 59 + ``` 60 + 61 + ### Optional fields 62 + 63 + | Field | Default | 64 + |-------|---------| 65 + | `mono_url` | `https://tangled.org/{handle}/mono.git` | 66 + | `license` | `ISC` | 67 + | `build_dir` | `_homebrew_build` | 68 + | `storage.endpoint` | `https://s3.{region}.scw.cloud` | 69 + | `storage.rclone_remote` | `scaleway` | 70 + | `tap.local_path` | `../homebrew-monopam` | 71 + | `binary.homepage` | `https://tangled.org/{handle}/{package}` | 72 + | `binary.exe_name` | Same as binary name | 73 + | `binary.head_deps` | `[]` | 74 + 75 + ## Usage 76 + 77 + ```bash 78 + # Show parsed configuration 79 + homebrew config -c homebrew.yml 80 + 81 + # Build bottles for current platform 82 + homebrew build -c homebrew.yml 83 + 84 + # Build specific binaries only 85 + homebrew build -c homebrew.yml prune merlint 86 + 87 + # Upload bottles to S3 88 + homebrew upload -c homebrew.yml 89 + 90 + # Generate formula files 91 + homebrew formula -c homebrew.yml 92 + 93 + # Full release: build + upload + update tap 94 + homebrew release -c homebrew.yml 95 + ``` 96 + 97 + ## API 98 + 99 + - `Homebrew.load_config` - Load YAML configuration 100 + - `Homebrew.detect_platform` - Detect current build platform 101 + - `Homebrew.build` - Build bottles 102 + - `Homebrew.upload` - Upload to S3 storage 103 + - `Homebrew.generate_formula` - Generate Ruby formula 104 + - `Homebrew.sha256_file` - Compute SHA256 digest 105 + - `Homebrew.release` - Full release workflow 106 + 107 + ## Related Work 108 + 109 + - [homebrew-core](https://github.com/Homebrew/homebrew-core) - The official Homebrew formula repository 110 + - [goreleaser](https://goreleaser.com/) - Go binary release automation (similar concept for Go) 111 + - [cargo-dist](https://github.com/axodotdev/cargo-dist) - Rust binary distribution tool 112 + 113 + ## License 114 + 115 + ISC License. See [LICENSE.md](LICENSE.md) for details.
+4
bin/dune
··· 1 + (executable 2 + (name main) 3 + (public_name homebrew) 4 + (libraries homebrew cmdliner vlog fmt logs))
+197
bin/main.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Cmdliner 7 + 8 + let config_file = 9 + let doc = "Path to the YAML configuration file." in 10 + Arg.( 11 + value 12 + & opt string "homebrew.yml" 13 + & info [ "c"; "config" ] ~docv:"FILE" ~doc) 14 + 15 + let binary_names = 16 + let doc = "Binary names to operate on (all if omitted)." in 17 + Arg.(value & pos_all string [] & info [] ~docv:"NAME" ~doc) 18 + 19 + let load_config file = 20 + match Homebrew.load_config file with 21 + | Ok c -> c 22 + | Error e -> 23 + Fmt.epr "Error: %s\n" e; 24 + exit 1 25 + 26 + (* build *) 27 + 28 + let build_cmd = 29 + let run () config_file names = 30 + let config = load_config config_file in 31 + match Homebrew.build config names with 32 + | Ok paths -> 33 + List.iter (fun p -> Fmt.pr "Built: %s@." p) paths; 34 + 0 35 + | Error e -> 36 + Logs.err (fun m -> m "%s" e); 37 + 1 38 + in 39 + let info = 40 + Cmd.info "build" 41 + ~doc:"Build Homebrew bottles for the current platform." 42 + ~man: 43 + [ 44 + `S Manpage.s_description; 45 + `P 46 + "Builds the monorepo with dune, then packages each configured \ 47 + binary as a Homebrew bottle tarball."; 48 + ] 49 + in 50 + Cmd.v info Term.(const run $ Vlog.setup "homebrew" $ config_file $ binary_names) 51 + 52 + (* upload *) 53 + 54 + let files_arg = 55 + let doc = "Bottle files to upload (all in build dir if omitted)." in 56 + Arg.(value & pos_all string [] & info [] ~docv:"FILE" ~doc) 57 + 58 + let upload_cmd = 59 + let run () config_file files = 60 + let config = load_config config_file in 61 + match Homebrew.upload config files with 62 + | Ok () -> 63 + Fmt.pr "Upload complete.@."; 64 + 0 65 + | Error e -> 66 + Logs.err (fun m -> m "%s" e); 67 + 1 68 + in 69 + let info = 70 + Cmd.info "upload" 71 + ~doc:"Upload bottles to S3-compatible object storage." 72 + ~man: 73 + [ 74 + `S Manpage.s_description; 75 + `P 76 + "Uploads bottle tarballs to the configured storage bucket using \ 77 + rclone. Each bottle is uploaded both with its dated name and as a \ 78 + 'latest' rolling release."; 79 + ] 80 + in 81 + Cmd.v info Term.(const run $ Vlog.setup "homebrew" $ config_file $ files_arg) 82 + 83 + (* formula *) 84 + 85 + let formula_cmd = 86 + let run () config_file names = 87 + let config = load_config config_file in 88 + let binaries = 89 + match names with 90 + | [] -> config.binaries 91 + | names -> 92 + List.filter 93 + (fun (b : Homebrew.binary) -> List.mem b.name names) 94 + config.binaries 95 + in 96 + List.iter 97 + (fun bin -> 98 + let formula = Homebrew.generate_formula config bin in 99 + Fmt.pr "%s" formula) 100 + binaries; 101 + 0 102 + in 103 + let info = 104 + Cmd.info "formula" 105 + ~doc:"Generate Homebrew Ruby formulas." 106 + ~man: 107 + [ 108 + `S Manpage.s_description; 109 + `P 110 + "Generates Ruby formula files for the configured binaries. \ 111 + Formulas include platform-specific bottle URLs and source build \ 112 + instructions."; 113 + ] 114 + in 115 + Cmd.v info Term.(const run $ Vlog.setup "homebrew" $ config_file $ binary_names) 116 + 117 + (* release *) 118 + 119 + let release_cmd = 120 + let run () config_file names = 121 + let config = load_config config_file in 122 + match Homebrew.release config names with 123 + | Ok () -> 0 124 + | Error e -> 125 + Logs.err (fun m -> m "%s" e); 126 + 1 127 + in 128 + let info = 129 + Cmd.info "release" 130 + ~doc:"Full release: build, upload, and update tap." 131 + ~man: 132 + [ 133 + `S Manpage.s_description; 134 + `P 135 + "Performs the full release workflow: builds bottles for the \ 136 + current platform, uploads them to object storage, generates or \ 137 + updates formula files with SHA256 checksums, and pushes the tap \ 138 + repository."; 139 + ] 140 + in 141 + Cmd.v info Term.(const run $ Vlog.setup "homebrew" $ config_file $ binary_names) 142 + 143 + (* config *) 144 + 145 + let config_cmd = 146 + let run () config_file = 147 + let config = load_config config_file in 148 + Fmt.pr "Handle: %s@." config.handle; 149 + Fmt.pr "License: %s@." config.license; 150 + Fmt.pr "Mono URL: %s@." config.mono_url; 151 + Fmt.pr "Storage: %s (bucket: %s, region: %s)@." config.storage.profile 152 + config.storage.bucket config.storage.region; 153 + Fmt.pr "Tap: %s@." config.tap.clone_url; 154 + Fmt.pr "Build dir: %s@." config.build_dir; 155 + Fmt.pr "Platform: %s@." 156 + (Homebrew.platform_to_string (Homebrew.detect_platform ())); 157 + Fmt.pr "Binaries:@."; 158 + List.iter 159 + (fun (b : Homebrew.binary) -> 160 + Fmt.pr " %-12s %s (%s)@." b.name b.description b.package) 161 + config.binaries; 162 + 0 163 + in 164 + let info = 165 + Cmd.info "config" 166 + ~doc:"Show parsed configuration." 167 + ~man: 168 + [ 169 + `S Manpage.s_description; 170 + `P "Loads and displays the parsed YAML configuration."; 171 + ] 172 + in 173 + Cmd.v info Term.(const run $ Vlog.setup "homebrew" $ config_file) 174 + 175 + (* main *) 176 + 177 + let main_cmd = 178 + let info = 179 + Cmd.info "homebrew" 180 + ~doc:"Homebrew bottle builder and tap manager for OCaml monorepos." 181 + ~man: 182 + [ 183 + `S Manpage.s_description; 184 + `P 185 + "Build, upload, and release Homebrew bottles from dune monorepos. \ 186 + Generates Ruby formulas with platform-specific SHA256 checksums, \ 187 + uploads bottles to S3-compatible storage, and manages tap \ 188 + repositories."; 189 + `S "CONFIGURATION"; 190 + `P 191 + "Configuration is read from a YAML file (default: homebrew.yml). \ 192 + See the README for the full configuration format."; 193 + ] 194 + in 195 + Cmd.group info [ build_cmd; upload_cmd; formula_cmd; release_cmd; config_cmd ] 196 + 197 + let () = exit (Cmd.eval main_cmd)
+34
dune-project
··· 1 + (lang dune 3.0) 2 + 3 + (name homebrew) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (source 12 + (uri https://tangled.org/gazagnaire.org/ocaml-homebrew)) 13 + 14 + (package 15 + (name homebrew) 16 + (synopsis "Homebrew bottle builder and tap manager for OCaml monorepos") 17 + (description 18 + "Build, upload, and release Homebrew bottles from dune monorepos. 19 + Generates Ruby formulas with platform-specific SHA256 checksums, 20 + uploads bottles to S3-compatible storage, and manages tap repositories.") 21 + (depends 22 + (ocaml (>= 5.1)) 23 + yamlt 24 + jsont 25 + (bos (>= 0.2)) 26 + (cmdliner (>= 1.3.0)) 27 + (fmt (>= 0.9)) 28 + (logs (>= 0.8)) 29 + (vlog (>= 0.1)) 30 + (fpath (>= 0.7)) 31 + (digestif (>= 1.0)) 32 + (astring (>= 0.8)) 33 + (alcotest :with-test) 34 + (crowbar :with-test)))
+15
fuzz/dune
··· 1 + ; Crowbar fuzz testing for homebrew 2 + ; 3 + ; To run: dune exec fuzz/fuzz_homebrew.exe 4 + ; With AFL: afl-fuzz -i fuzz/corpus -o fuzz/findings -- ./_build/default/fuzz/fuzz_homebrew.exe @@ 5 + 6 + (executable 7 + (name fuzz_homebrew) 8 + (modules fuzz_homebrew) 9 + (libraries homebrew crowbar)) 10 + 11 + (rule 12 + (alias fuzz) 13 + (deps fuzz_homebrew.exe) 14 + (action 15 + (run %{exe:fuzz_homebrew.exe})))
+59
fuzz/fuzz_homebrew.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Crowbar 7 + 8 + (* Fuzz the YAML config parser: it should never raise *) 9 + let test_yaml_parse input = 10 + let _ = Yamlt.decode_string Homebrew.config_jsont input in 11 + () 12 + 13 + (* Fuzz formula generation with arbitrary binary configs *) 14 + let gen_binary = 15 + map [ bytes; bytes; bytes ] (fun name package description -> 16 + let name = 17 + if String.length name = 0 then "x" else String.sub name 0 (min 50 (String.length name)) 18 + in 19 + let package = 20 + if String.length package = 0 then "y" 21 + else String.sub package 0 (min 50 (String.length package)) 22 + in 23 + let description = String.sub description 0 (min 100 (String.length description)) in 24 + ({ 25 + Homebrew.name; 26 + package; 27 + description; 28 + homepage = ""; 29 + head_deps = []; 30 + exe_name = None; 31 + } 32 + : Homebrew.binary)) 33 + 34 + let test_formula_gen (bin : Homebrew.binary) = 35 + let config : Homebrew.config = 36 + { 37 + handle = "test"; 38 + mono_url = "https://example.com/mono.git"; 39 + license = "ISC"; 40 + storage = 41 + { 42 + bucket = "b"; 43 + region = "r"; 44 + profile = "p"; 45 + endpoint = "e"; 46 + rclone_remote = "s"; 47 + }; 48 + tap = 49 + { clone_url = "c"; push_url = "p"; local_path = "l" }; 50 + binaries = []; 51 + build_dir = "_build"; 52 + } 53 + in 54 + let formula = Homebrew.generate_formula config bin in 55 + check (String.length formula > 0) 56 + 57 + let () = 58 + add_test ~name:"homebrew: yaml parse no crash" [ bytes ] test_yaml_parse; 59 + add_test ~name:"homebrew: formula gen no crash" [ gen_binary ] test_formula_gen
+4
lib/dune
··· 1 + (library 2 + (name homebrew) 3 + (public_name homebrew) 4 + (libraries yamlt jsont bos fmt fpath logs digestif astring))
+673
lib/homebrew.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + module Log = (val Logs.src_log (Logs.Src.create "homebrew")) 7 + 8 + (* {1 Configuration} *) 9 + 10 + type storage = { 11 + bucket : string; 12 + region : string; 13 + profile : string; 14 + endpoint : string; 15 + rclone_remote : string; 16 + } 17 + 18 + type tap = { 19 + clone_url : string; 20 + push_url : string; 21 + local_path : string; 22 + } 23 + 24 + type head_dep = { 25 + dep_name : string; 26 + dep_type : [ `Build | `Recommended ]; 27 + } 28 + 29 + type binary = { 30 + name : string; 31 + package : string; 32 + description : string; 33 + homepage : string; 34 + head_deps : head_dep list; 35 + exe_name : string option; 36 + } 37 + 38 + type config = { 39 + handle : string; 40 + mono_url : string; 41 + license : string; 42 + storage : storage; 43 + tap : tap; 44 + binaries : binary list; 45 + build_dir : string; 46 + } 47 + 48 + type platform = 49 + | Arm64_sonoma 50 + | Sonoma 51 + | X86_64_linux 52 + | Arm64_linux 53 + | Unknown of string 54 + 55 + (* {2 Jsont codecs} *) 56 + 57 + let dep_type_jsont : [ `Build | `Recommended ] Jsont.t = 58 + let dec s = 59 + match s with 60 + | "build" -> `Build 61 + | "recommended" -> `Recommended 62 + | _ -> `Build 63 + in 64 + let enc = function `Build -> "build" | `Recommended -> "recommended" in 65 + Jsont.map ~dec ~enc Jsont.string 66 + 67 + let head_dep_jsont : head_dep Jsont.t = 68 + Jsont.Object.map ~kind:"head_dep" (fun dep_name dep_type -> 69 + { dep_name; dep_type }) 70 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun d -> d.dep_name) 71 + |> Jsont.Object.mem "type" dep_type_jsont ~dec_absent:`Build 72 + ~enc:(fun d -> d.dep_type) 73 + |> Jsont.Object.finish 74 + 75 + let binary_jsont : binary Jsont.t = 76 + Jsont.Object.map ~kind:"binary" 77 + (fun name package description homepage head_deps exe_name -> 78 + { name; package; description; homepage; head_deps; exe_name }) 79 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun b -> b.name) 80 + |> Jsont.Object.mem "package" Jsont.string ~enc:(fun b -> b.package) 81 + |> Jsont.Object.mem "description" Jsont.string ~enc:(fun b -> b.description) 82 + |> Jsont.Object.mem "homepage" Jsont.string ~dec_absent:"" 83 + ~enc:(fun b -> b.homepage) 84 + |> Jsont.Object.mem "head_deps" (Jsont.list head_dep_jsont) ~dec_absent:[] 85 + ~enc:(fun b -> b.head_deps) 86 + |> Jsont.Object.opt_mem "exe_name" Jsont.string ~enc:(fun b -> b.exe_name) 87 + |> Jsont.Object.finish 88 + 89 + let storage_jsont : storage Jsont.t = 90 + Jsont.Object.map ~kind:"storage" 91 + (fun bucket region profile endpoint rclone_remote -> 92 + let endpoint = 93 + match endpoint with 94 + | Some e -> e 95 + | None -> Fmt.str "https://s3.%s.scw.cloud" region 96 + in 97 + let rclone_remote = 98 + match rclone_remote with Some r -> r | None -> "scaleway" 99 + in 100 + { bucket; region; profile; endpoint; rclone_remote }) 101 + |> Jsont.Object.mem "bucket" Jsont.string ~enc:(fun s -> s.bucket) 102 + |> Jsont.Object.mem "region" Jsont.string ~enc:(fun s -> s.region) 103 + |> Jsont.Object.mem "profile" Jsont.string ~enc:(fun s -> s.profile) 104 + |> Jsont.Object.opt_mem "endpoint" Jsont.string 105 + ~enc:(fun s -> Some s.endpoint) 106 + |> Jsont.Object.opt_mem "rclone_remote" Jsont.string 107 + ~enc:(fun s -> Some s.rclone_remote) 108 + |> Jsont.Object.finish 109 + 110 + let tap_jsont : tap Jsont.t = 111 + Jsont.Object.map ~kind:"tap" (fun clone_url push_url local_path -> 112 + let local_path = 113 + match local_path with Some p -> p | None -> "../homebrew-monopam" 114 + in 115 + { clone_url; push_url; local_path }) 116 + |> Jsont.Object.mem "clone_url" Jsont.string ~enc:(fun t -> t.clone_url) 117 + |> Jsont.Object.mem "push_url" Jsont.string ~enc:(fun t -> t.push_url) 118 + |> Jsont.Object.opt_mem "local_path" Jsont.string 119 + ~enc:(fun t -> Some t.local_path) 120 + |> Jsont.Object.finish 121 + 122 + let config_jsont : config Jsont.t = 123 + Jsont.Object.map ~kind:"config" 124 + (fun handle mono_url license storage tap binaries build_dir -> 125 + let mono_url = 126 + match mono_url with 127 + | Some u -> u 128 + | None -> Fmt.str "https://tangled.org/%s/mono.git" handle 129 + in 130 + let license = match license with Some l -> l | None -> "ISC" in 131 + let build_dir = 132 + match build_dir with Some d -> d | None -> "_homebrew_build" 133 + in 134 + { handle; mono_url; license; storage; tap; binaries; build_dir }) 135 + |> Jsont.Object.mem "handle" Jsont.string ~enc:(fun c -> c.handle) 136 + |> Jsont.Object.opt_mem "mono_url" Jsont.string 137 + ~enc:(fun c -> Some c.mono_url) 138 + |> Jsont.Object.opt_mem "license" Jsont.string 139 + ~enc:(fun c -> Some c.license) 140 + |> Jsont.Object.mem "storage" storage_jsont ~enc:(fun c -> c.storage) 141 + |> Jsont.Object.mem "tap" tap_jsont ~enc:(fun c -> c.tap) 142 + |> Jsont.Object.mem "binaries" (Jsont.list binary_jsont) ~enc:(fun c -> 143 + c.binaries) 144 + |> Jsont.Object.opt_mem "build_dir" Jsont.string 145 + ~enc:(fun c -> Some c.build_dir) 146 + |> Jsont.Object.finish 147 + 148 + let load_config path = 149 + match Bos.OS.File.read (Fpath.v path) with 150 + | Error (`Msg e) -> Error e 151 + | Ok content -> ( 152 + match Yamlt.decode_string config_jsont content with 153 + | Ok config -> 154 + Log.info (fun m -> 155 + m "Loaded config: %d binaries" (List.length config.binaries)); 156 + Ok config 157 + | Error e -> Error (Fmt.str "config parse error: %s" e)) 158 + 159 + (* {1 Platform Detection} *) 160 + 161 + let detect_platform () = 162 + let os = 163 + match Bos.OS.Cmd.run_out Bos.Cmd.(v "uname" % "-s") |> Bos.OS.Cmd.out_string with 164 + | Ok (s, _) -> String.lowercase_ascii (String.trim s) 165 + | Error _ -> "unknown" 166 + in 167 + let arch = 168 + match Bos.OS.Cmd.run_out Bos.Cmd.(v "uname" % "-m") |> Bos.OS.Cmd.out_string with 169 + | Ok (s, _) -> String.trim s 170 + | Error _ -> "unknown" 171 + in 172 + match (os, arch) with 173 + | "darwin", "arm64" -> Arm64_sonoma 174 + | "darwin", "x86_64" -> Sonoma 175 + | "linux", "x86_64" -> X86_64_linux 176 + | "linux", "aarch64" -> Arm64_linux 177 + | _ -> Unknown (Fmt.str "%s_%s" os arch) 178 + 179 + let platform_to_string = function 180 + | Arm64_sonoma -> "arm64_sonoma" 181 + | Sonoma -> "sonoma" 182 + | X86_64_linux -> "x86_64_linux" 183 + | Arm64_linux -> "arm64_linux" 184 + | Unknown s -> s 185 + 186 + (* {1 SHA256} *) 187 + 188 + let sha256_file path = 189 + match Bos.OS.File.read (Fpath.v path) with 190 + | Error (`Msg e) -> Error e 191 + | Ok content -> 192 + let hash = Digestif.SHA256.digest_string content in 193 + Ok (Digestif.SHA256.to_hex hash) 194 + 195 + (* {1 Building} *) 196 + 197 + let version_string () = 198 + let t = Unix.localtime (Unix.gettimeofday ()) in 199 + Fmt.str "%04d%02d%02d" (1900 + t.tm_year) (1 + t.tm_mon) t.tm_mday 200 + 201 + let find_exe config (bin : binary) = 202 + let exe_name = match bin.exe_name with Some e -> e | None -> bin.name in 203 + let candidates = 204 + [ 205 + Fmt.str "_build/default/%s/bin/%s.exe" bin.package exe_name; 206 + Fmt.str "_build/default/%s/bin/main.exe" bin.package; 207 + ] 208 + in 209 + let rec try_paths = function 210 + | [] -> None 211 + | p :: rest -> 212 + let fpath = Fpath.v p in 213 + if Bos.OS.File.exists fpath = Ok true then Some p else try_paths rest 214 + in 215 + ignore config; 216 + try_paths candidates 217 + 218 + let build_one config (bin : binary) platform version = 219 + let platform_str = platform_to_string platform in 220 + let bottle_name = 221 + Fmt.str "%s-%s.%s.bottle.tar.gz" bin.name version platform_str 222 + in 223 + let bottle_dir = 224 + Fpath.(v config.build_dir / Fmt.str "%s-%s" bin.name version) 225 + in 226 + match find_exe config bin with 227 + | None -> 228 + Log.warn (fun m -> m "Executable not found for %s, skipping" bin.name); 229 + Ok None 230 + | Some exe -> 231 + let open Result in 232 + let ( let* ) = bind in 233 + let* _ = Bos.OS.Dir.create Fpath.(bottle_dir / "bin") |> Result.map_error (fun (`Msg e) -> e) in 234 + let* _ = 235 + Bos.OS.Cmd.run 236 + Bos.Cmd.(v "cp" % exe % Fpath.to_string Fpath.(bottle_dir / "bin" / bin.name)) 237 + |> Result.map_error (fun (`Msg e) -> e) 238 + in 239 + let* _ = 240 + Bos.OS.Cmd.run 241 + Bos.Cmd.(v "chmod" % "+x" % Fpath.to_string Fpath.(bottle_dir / "bin" / bin.name)) 242 + |> Result.map_error (fun (`Msg e) -> e) 243 + in 244 + let bottle_path = Fpath.(v config.build_dir / bottle_name) in 245 + let* _ = 246 + Bos.OS.Cmd.run 247 + Bos.Cmd.( 248 + v "tar" % "czf" % Fpath.to_string bottle_path % "-C" % config.build_dir 249 + % Fmt.str "%s-%s" bin.name version) 250 + |> Result.map_error (fun (`Msg e) -> e) 251 + in 252 + let* _ = Bos.OS.Dir.delete ~recurse:true bottle_dir |> Result.map_error (fun (`Msg e) -> e) in 253 + Log.info (fun m -> m "Built: %s" (Fpath.to_string bottle_path)); 254 + Ok (Some (Fpath.to_string bottle_path)) 255 + 256 + let build config names = 257 + let open Result in 258 + let ( let* ) = bind in 259 + let platform = detect_platform () in 260 + let version = version_string () in 261 + Log.info (fun m -> 262 + m "Building for platform: %s" (platform_to_string platform)); 263 + let* _ = Bos.OS.Dir.create (Fpath.v config.build_dir) |> Result.map_error (fun (`Msg e) -> e) in 264 + (* Build the monorepo first *) 265 + let* _ = 266 + Bos.OS.Cmd.run Bos.Cmd.(v "opam" % "exec" % "--" % "dune" % "build" % "@install") 267 + |> Result.map_error (fun (`Msg e) -> e) 268 + in 269 + let binaries = 270 + match names with 271 + | [] -> config.binaries 272 + | names -> 273 + List.filter (fun (b : binary) -> List.mem b.name names) config.binaries 274 + in 275 + let results = 276 + List.filter_map 277 + (fun bin -> 278 + match build_one config bin platform version with 279 + | Ok (Some path) -> Some (Ok path) 280 + | Ok None -> None 281 + | Error e -> Some (Error e)) 282 + binaries 283 + in 284 + let errors, paths = 285 + List.fold_left 286 + (fun (errs, paths) r -> 287 + match r with Ok p -> (errs, p :: paths) | Error e -> (e :: errs, paths)) 288 + ([], []) results 289 + in 290 + match errors with 291 + | [] -> Ok (List.rev paths) 292 + | errs -> Error (String.concat "\n" errs) 293 + 294 + (* {1 Uploading} *) 295 + 296 + let bottle_url config = 297 + Fmt.str "https://%s.s3.%s.scw.cloud" config.storage.bucket 298 + config.storage.region 299 + 300 + let upload_one config file = 301 + let open Result in 302 + let ( let* ) = bind in 303 + let basename = Filename.basename file in 304 + let name = 305 + match Astring.String.cut ~sep:"-" basename with 306 + | Some (n, _) -> n 307 + | None -> basename 308 + in 309 + Log.info (fun m -> m "Uploading: %s" basename); 310 + let remote = config.storage.rclone_remote in 311 + let bucket = config.storage.bucket in 312 + let* _ = 313 + Bos.OS.Cmd.run 314 + Bos.Cmd.( 315 + v "rclone" % "copyto" % file 316 + % Fmt.str "%s:%s/%s" remote bucket basename) 317 + |> Result.map_error (fun (`Msg e) -> e) 318 + in 319 + (* Upload as "latest" too *) 320 + let suffix = 321 + match Astring.String.cut ~sep:"." ~rev:false basename with 322 + | Some (_, rest) -> 323 + (* Skip past the date part: name-YYYYMMDD.rest *) 324 + rest 325 + | None -> basename 326 + in 327 + (* Extract everything after name-YYYYMMDD. *) 328 + let latest_suffix = 329 + let after_name = 330 + match Astring.String.cut ~sep:"-" basename with 331 + | Some (_, rest) -> rest 332 + | None -> basename 333 + in 334 + match Astring.String.cut ~sep:"." after_name with 335 + | Some (_, rest) -> rest 336 + | None -> suffix 337 + in 338 + let latest_name = Fmt.str "%s-latest.%s" name latest_suffix in 339 + Log.info (fun m -> m "Uploading as: %s" latest_name); 340 + let* _ = 341 + Bos.OS.Cmd.run 342 + Bos.Cmd.( 343 + v "rclone" % "copyto" % file 344 + % Fmt.str "%s:%s/%s" remote bucket latest_name) 345 + |> Result.map_error (fun (`Msg e) -> e) 346 + in 347 + Log.info (fun m -> 348 + m "URL: %s/%s" (bottle_url config) basename); 349 + Ok () 350 + 351 + let upload config files = 352 + let open Result in 353 + let ( let* ) = bind in 354 + let files = 355 + match files with 356 + | [] -> 357 + let dir = config.build_dir in 358 + (match Bos.OS.Dir.contents (Fpath.v dir) with 359 + | Ok paths -> 360 + List.filter_map 361 + (fun p -> 362 + let s = Fpath.to_string p in 363 + if Astring.String.is_suffix ~affix:".tar.gz" s then Some s 364 + else None) 365 + paths 366 + | Error _ -> []) 367 + | files -> files 368 + in 369 + let rec upload_all = function 370 + | [] -> Ok () 371 + | f :: rest -> 372 + let* () = upload_one config f in 373 + upload_all rest 374 + in 375 + upload_all files 376 + 377 + (* {1 Formula Generation} *) 378 + 379 + let capitalize_first s = 380 + if String.length s = 0 then s 381 + else 382 + let first = Char.uppercase_ascii s.[0] in 383 + String.make 1 first ^ String.sub s 1 (String.length s - 1) 384 + 385 + (* Convert kebab-case name to CamelCase class name *) 386 + let class_name name = 387 + let parts = Astring.String.cuts ~sep:"-" name in 388 + String.concat "" (List.map capitalize_first parts) 389 + 390 + let generate_formula config (bin : binary) = 391 + let url = bottle_url config in 392 + let homepage = 393 + if bin.homepage <> "" then bin.homepage 394 + else Fmt.str "https://tangled.org/%s/%s" config.handle bin.package 395 + in 396 + let buf = Buffer.create 1024 in 397 + let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 398 + pr "class %s < Formula\n" (class_name bin.name); 399 + pr " desc \"%s\"\n" bin.description; 400 + pr " homepage \"%s\"\n" homepage; 401 + pr " license \"%s\"\n" config.license; 402 + pr " version \"latest\"\n"; 403 + pr "\n"; 404 + pr " on_macos do\n"; 405 + pr " on_arm do\n"; 406 + pr " url \"%s/%s-latest.arm64_sonoma.bottle.tar.gz\"\n" url bin.name; 407 + pr " sha256 :no_check\n"; 408 + pr " end\n"; 409 + pr " on_intel do\n"; 410 + pr " url \"%s/%s-latest.sonoma.bottle.tar.gz\"\n" url bin.name; 411 + pr " sha256 :no_check\n"; 412 + pr " end\n"; 413 + pr " end\n"; 414 + pr "\n"; 415 + pr " on_linux do\n"; 416 + pr " url \"%s/%s-latest.x86_64_linux.bottle.tar.gz\"\n" url bin.name; 417 + pr " sha256 :no_check\n"; 418 + pr " end\n"; 419 + pr "\n"; 420 + pr " head \"%s\", branch: \"main\"\n" config.mono_url; 421 + pr "\n"; 422 + pr " head do\n"; 423 + pr " depends_on \"ocaml\" => :build\n"; 424 + pr " depends_on \"opam\" => :build\n"; 425 + pr " depends_on \"dune\" => :build\n"; 426 + List.iter 427 + (fun (dep : head_dep) -> 428 + let typ = 429 + match dep.dep_type with 430 + | `Build -> ":build" 431 + | `Recommended -> ":recommended" 432 + in 433 + pr " depends_on \"%s\" => %s\n" dep.dep_name typ) 434 + bin.head_deps; 435 + pr " end\n"; 436 + pr "\n"; 437 + pr " def install\n"; 438 + pr " if build.head?\n"; 439 + pr " system \"opam\", \"init\", \"--disable-sandboxing\", \"--no-setup\", \"-y\" unless File.exist?(\"#{Dir.home}/.opam\")\n"; 440 + pr " system \"opam\", \"install\", \".\", \"--deps-only\", \"--with-test=false\", \"-y\", \"--working-dir\"\n"; 441 + let exe_name = match bin.exe_name with Some e -> e | None -> bin.name in 442 + pr " system \"opam\", \"exec\", \"--\", \"dune\", \"build\", \"%s/bin/%s.exe\"\n" 443 + bin.package exe_name; 444 + pr " bin.install \"_build/default/%s/bin/%s.exe\" => \"%s\"\n" 445 + bin.package exe_name bin.name; 446 + pr " else\n"; 447 + pr " bin.install \"%s\"\n" bin.name; 448 + pr " end\n"; 449 + pr " end\n"; 450 + pr "\n"; 451 + pr " test do\n"; 452 + pr " system bin/\"%s\", \"--help\"\n" bin.name; 453 + pr " end\n"; 454 + pr "end\n"; 455 + Buffer.contents buf 456 + 457 + (* {1 Tap Management} *) 458 + 459 + let platform_of_string = function 460 + | "arm64_sonoma" -> Arm64_sonoma 461 + | "sonoma" -> Sonoma 462 + | "x86_64_linux" -> X86_64_linux 463 + | "arm64_linux" -> Arm64_linux 464 + | s -> Unknown s 465 + 466 + let update_formula_checksums config tap_dir checksums = 467 + let open Result in 468 + let ( let* ) = bind in 469 + let url = bottle_url config in 470 + let update_one (name, sha256, platform) = 471 + let formula_path = Fpath.(v tap_dir / "Formula" / (name ^ ".rb")) in 472 + match Bos.OS.File.read formula_path with 473 + | Error (`Msg e) -> 474 + Log.warn (fun m -> m "Formula not found for %s: %s" name e); 475 + Ok () 476 + | Ok content -> 477 + let platform_str = platform_to_string platform in 478 + Log.info (fun m -> 479 + m "%s (%s): %s..." name platform_str 480 + (String.sub sha256 0 (min 16 (String.length sha256)))); 481 + let version = version_string () in 482 + let new_url = 483 + Fmt.str "%s/%s-%s.%s.bottle.tar.gz" url name version platform_str 484 + in 485 + let marker, next_line_marker = 486 + match platform with 487 + | Arm64_sonoma -> ("on_arm do", true) 488 + | Sonoma -> ("on_intel do", true) 489 + | X86_64_linux | Arm64_linux -> ("on_linux do", true) 490 + | Unknown _ -> ("", false) 491 + in 492 + if not next_line_marker then Ok () 493 + else 494 + let lines = Astring.String.cuts ~sep:"\n" content in 495 + let rec update_lines acc found = function 496 + | [] -> List.rev acc 497 + | line :: rest when found = 0 && Astring.String.is_infix ~affix:marker line -> 498 + update_lines (line :: acc) 1 rest 499 + | line :: rest when found = 1 && Astring.String.is_infix ~affix:"url " line -> 500 + let indent = 501 + let trimmed = Astring.String.trim line in 502 + let diff = String.length line - String.length trimmed in 503 + String.make diff ' ' 504 + in 505 + let new_line = Fmt.str "%surl \"%s\"" indent new_url in 506 + update_lines (new_line :: acc) 2 rest 507 + | line :: rest when found = 2 && Astring.String.is_infix ~affix:"sha256" line -> 508 + let indent = 509 + let trimmed = Astring.String.trim line in 510 + let diff = String.length line - String.length trimmed in 511 + String.make diff ' ' 512 + in 513 + let new_line = Fmt.str "%ssha256 \"%s\"" indent sha256 in 514 + update_lines (new_line :: acc) 0 rest 515 + | line :: rest -> update_lines (line :: acc) found rest 516 + in 517 + let updated = update_lines [] 0 lines in 518 + let updated_content = String.concat "\n" updated in 519 + (* Also update version *) 520 + let updated_content = 521 + let lines = Astring.String.cuts ~sep:"\n" updated_content in 522 + let lines = 523 + List.map 524 + (fun line -> 525 + if Astring.String.is_infix ~affix:"version " line 526 + && Astring.String.is_infix ~affix:"\"" line 527 + then 528 + let indent = 529 + let trimmed = Astring.String.trim line in 530 + let diff = String.length line - String.length trimmed in 531 + String.make diff ' ' 532 + in 533 + Fmt.str "%sversion \"%s\"" indent (version_string ()) 534 + else line) 535 + lines 536 + in 537 + String.concat "\n" lines 538 + in 539 + let* _ = 540 + Bos.OS.File.write formula_path updated_content 541 + |> Result.map_error (fun (`Msg e) -> e) 542 + in 543 + Ok () 544 + in 545 + let rec update_all = function 546 + | [] -> Ok () 547 + | c :: rest -> 548 + let* () = update_one c in 549 + update_all rest 550 + in 551 + update_all checksums 552 + 553 + (* {1 Release} *) 554 + 555 + let ensure_tap config = 556 + let open Result in 557 + let ( let* ) = bind in 558 + let tap_path = Fpath.v config.tap.local_path in 559 + if Bos.OS.Dir.exists tap_path = Ok true then ( 560 + Log.info (fun m -> m "Pulling tap..."); 561 + let* _ = 562 + Bos.OS.Cmd.run 563 + Bos.Cmd.(v "git" % "-C" % config.tap.local_path % "pull" % "--ff-only") 564 + |> Result.map_error (fun (`Msg e) -> e) 565 + in 566 + Ok ()) 567 + else ( 568 + Log.info (fun m -> m "Cloning tap..."); 569 + let* _ = 570 + Bos.OS.Cmd.run 571 + Bos.Cmd.( 572 + v "git" % "clone" % config.tap.clone_url % config.tap.local_path) 573 + |> Result.map_error (fun (`Msg e) -> e) 574 + in 575 + Ok ()) 576 + 577 + let commit_and_push_tap config = 578 + let open Result in 579 + let ( let* ) = bind in 580 + let dir = config.tap.local_path in 581 + let has_changes = 582 + match 583 + Bos.OS.Cmd.run_out 584 + Bos.Cmd.(v "git" % "-C" % dir % "status" % "--porcelain") 585 + |> Bos.OS.Cmd.out_string 586 + with 587 + | Ok (s, _) -> String.trim s <> "" 588 + | Error _ -> false 589 + in 590 + if not has_changes then ( 591 + Log.info (fun m -> m "No changes to push."); 592 + Ok ()) 593 + else 594 + let version = version_string () in 595 + let* _ = 596 + Bos.OS.Cmd.run Bos.Cmd.(v "git" % "-C" % dir % "add" % "-A") 597 + |> Result.map_error (fun (`Msg e) -> e) 598 + in 599 + let* _ = 600 + Bos.OS.Cmd.run 601 + Bos.Cmd.( 602 + v "git" % "-C" % dir % "commit" % "-m" 603 + % Fmt.str "Update bottles %s" version) 604 + |> Result.map_error (fun (`Msg e) -> e) 605 + in 606 + let* _ = 607 + Bos.OS.Cmd.run Bos.Cmd.(v "git" % "-C" % dir % "push") 608 + |> Result.map_error (fun (`Msg e) -> e) 609 + in 610 + Log.info (fun m -> m "Tap pushed successfully."); 611 + Ok () 612 + 613 + let release config names = 614 + let open Result in 615 + let ( let* ) = bind in 616 + (* Step 1: Build *) 617 + Log.app (fun m -> m "Step 1: Building..."); 618 + let* bottles = build config names in 619 + (* Step 2: Upload *) 620 + Log.app (fun m -> m "Step 2: Uploading..."); 621 + let* () = upload config bottles in 622 + (* Step 3: Update tap *) 623 + Log.app (fun m -> m "Step 3: Updating tap..."); 624 + let* () = ensure_tap config in 625 + (* Compute checksums *) 626 + let checksums = 627 + List.filter_map 628 + (fun path -> 629 + let basename = Filename.basename path in 630 + let name = 631 + match Astring.String.cut ~sep:"-" basename with 632 + | Some (n, _) -> n 633 + | None -> basename 634 + in 635 + let rest = 636 + match Astring.String.cut ~sep:"-" basename with 637 + | Some (_, r) -> r 638 + | None -> "" 639 + in 640 + let platform_str = 641 + match Astring.String.cut ~sep:"." rest with 642 + | Some (_, r) -> ( 643 + match Astring.String.cut ~rev:true ~sep:".bottle" r with 644 + | Some (p, _) -> p 645 + | None -> r) 646 + | None -> "" 647 + in 648 + match sha256_file path with 649 + | Ok sha -> Some (name, sha, platform_of_string platform_str) 650 + | Error e -> 651 + Log.warn (fun m -> m "SHA256 failed for %s: %s" path e); 652 + None) 653 + bottles 654 + in 655 + (* Generate missing formulas *) 656 + let formula_dir = Fpath.(v config.tap.local_path / "Formula") in 657 + let* _ = 658 + Bos.OS.Dir.create formula_dir |> Result.map_error (fun (`Msg e) -> e) 659 + in 660 + List.iter 661 + (fun (bin : binary) -> 662 + let formula_path = Fpath.(formula_dir / (bin.name ^ ".rb")) in 663 + if Bos.OS.File.exists formula_path <> Ok true then ( 664 + Log.info (fun m -> m "Generating formula for %s" bin.name); 665 + let content = generate_formula config bin in 666 + ignore (Bos.OS.File.write formula_path content))) 667 + config.binaries; 668 + (* Update checksums *) 669 + let* () = update_formula_checksums config config.tap.local_path checksums in 670 + (* Commit and push *) 671 + let* () = commit_and_push_tap config in 672 + Log.app (fun m -> m "Release complete. Bottles at: %s" (bottle_url config)); 673 + Ok ()
+106
lib/homebrew.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Homebrew bottle builder and tap manager for OCaml monorepos. *) 7 + 8 + (** {1 Configuration} *) 9 + 10 + (** Storage configuration for S3-compatible object storage. *) 11 + type storage = { 12 + bucket : string; 13 + region : string; 14 + profile : string; 15 + endpoint : string; 16 + rclone_remote : string; 17 + } 18 + 19 + (** Tap repository configuration. *) 20 + type tap = { 21 + clone_url : string; 22 + push_url : string; 23 + local_path : string; 24 + } 25 + 26 + (** Head-build dependency specification. *) 27 + type head_dep = { 28 + dep_name : string; 29 + dep_type : [ `Build | `Recommended ]; 30 + } 31 + 32 + (** Binary specification. *) 33 + type binary = { 34 + name : string; 35 + package : string; 36 + description : string; 37 + homepage : string; 38 + head_deps : head_dep list; 39 + exe_name : string option; 40 + } 41 + 42 + (** Top-level configuration. *) 43 + type config = { 44 + handle : string; 45 + mono_url : string; 46 + license : string; 47 + storage : storage; 48 + tap : tap; 49 + binaries : binary list; 50 + build_dir : string; 51 + } 52 + 53 + val config_jsont : config Jsont.t 54 + (** Jsont codec for {!config}. *) 55 + 56 + val load_config : string -> (config, string) result 57 + (** [load_config path] loads a YAML configuration file. *) 58 + 59 + (** {1 Platform Detection} *) 60 + 61 + (** Build platform identifier. *) 62 + type platform = 63 + | Arm64_sonoma 64 + | Sonoma 65 + | X86_64_linux 66 + | Arm64_linux 67 + | Unknown of string 68 + 69 + val detect_platform : unit -> platform 70 + (** [detect_platform ()] detects the current build platform. *) 71 + 72 + val platform_to_string : platform -> string 73 + (** [platform_to_string p] returns the platform string for bottle names. *) 74 + 75 + (** {1 Building} *) 76 + 77 + val build : config -> string list -> (string list, string) result 78 + (** [build config names] builds bottles for the given binary names (or all if 79 + empty). Returns the list of built bottle file paths. *) 80 + 81 + (** {1 Uploading} *) 82 + 83 + val upload : config -> string list -> (unit, string) result 84 + (** [upload config files] uploads bottle files to object storage. *) 85 + 86 + (** {1 Formula Generation} *) 87 + 88 + val generate_formula : config -> binary -> string 89 + (** [generate_formula config binary] generates a Homebrew Ruby formula. *) 90 + 91 + (** {1 SHA256 Checksums} *) 92 + 93 + val sha256_file : string -> (string, string) result 94 + (** [sha256_file path] computes the SHA256 hex digest of a file. *) 95 + 96 + (** {1 Tap Management} *) 97 + 98 + val update_formula_checksums : 99 + config -> string -> (string * string * platform) list -> (unit, string) result 100 + (** [update_formula_checksums config tap_dir checksums] updates SHA256 101 + checksums in formula files. [checksums] is a list of 102 + [(name, sha256, platform)] triples. *) 103 + 104 + val release : config -> string list -> (unit, string) result 105 + (** [release config names] performs a full release: build, upload, and update 106 + tap with SHA256 checksums. *)
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries homebrew alcotest))
+1
test/test.ml
··· 1 + let () = Alcotest.run "homebrew" Test_homebrew.suite
+268
test/test_homebrew.ml
··· 1 + let sample_yaml = 2 + {| 3 + handle: test.example 4 + storage: 5 + bucket: test-bottles 6 + region: us-east 7 + profile: test-profile 8 + tap: 9 + clone_url: https://example.com/tap.git 10 + push_url: git@example.com:tap 11 + binaries: 12 + - name: myapp 13 + package: ocaml-myapp 14 + description: "A test application" 15 + homepage: https://example.com/myapp 16 + - name: mytool 17 + package: ocaml-mytool 18 + description: "A test tool" 19 + head_deps: 20 + - name: afl-fuzz 21 + type: recommended 22 + |} 23 + 24 + let test_load_config () = 25 + match Yamlt.decode_string Homebrew.config_jsont sample_yaml with 26 + | Error e -> Alcotest.fail (Fmt.str "parse error: %s" e) 27 + | Ok config -> 28 + Alcotest.(check string) "handle" "test.example" config.handle; 29 + Alcotest.(check string) "bucket" "test-bottles" config.storage.bucket; 30 + Alcotest.(check string) "region" "us-east" config.storage.region; 31 + Alcotest.(check string) "profile" "test-profile" config.storage.profile; 32 + Alcotest.(check string) 33 + "endpoint" "https://s3.us-east.scw.cloud" config.storage.endpoint; 34 + Alcotest.(check string) "rclone_remote" "scaleway" 35 + config.storage.rclone_remote; 36 + Alcotest.(check string) "clone_url" "https://example.com/tap.git" 37 + config.tap.clone_url; 38 + Alcotest.(check string) "push_url" "git@example.com:tap" 39 + config.tap.push_url; 40 + Alcotest.(check string) "local_path" "../homebrew-monopam" 41 + config.tap.local_path; 42 + Alcotest.(check int) "binary count" 2 (List.length config.binaries); 43 + let b0 = List.nth config.binaries 0 in 44 + Alcotest.(check string) "bin name" "myapp" b0.name; 45 + Alcotest.(check string) "bin package" "ocaml-myapp" b0.package; 46 + Alcotest.(check string) "bin desc" "A test application" b0.description; 47 + Alcotest.(check string) "bin homepage" "https://example.com/myapp" 48 + b0.homepage; 49 + let b1 = List.nth config.binaries 1 in 50 + Alcotest.(check string) "bin1 name" "mytool" b1.name; 51 + Alcotest.(check int) "bin1 head_deps" 1 (List.length b1.head_deps); 52 + let dep = List.nth b1.head_deps 0 in 53 + Alcotest.(check string) "dep name" "afl-fuzz" dep.dep_name 54 + 55 + let test_defaults () = 56 + let yaml = 57 + {| 58 + handle: alice.example 59 + storage: 60 + bucket: b 61 + region: r 62 + profile: p 63 + tap: 64 + clone_url: https://c 65 + push_url: git@p 66 + binaries: [] 67 + |} 68 + in 69 + match Yamlt.decode_string Homebrew.config_jsont yaml with 70 + | Error e -> Alcotest.fail (Fmt.str "parse error: %s" e) 71 + | Ok config -> 72 + Alcotest.(check string) 73 + "mono_url" "https://tangled.org/alice.example/mono.git" config.mono_url; 74 + Alcotest.(check string) "license" "ISC" config.license; 75 + Alcotest.(check string) "build_dir" "_homebrew_build" config.build_dir 76 + 77 + let test_platform_roundtrip () = 78 + let platforms = 79 + [ 80 + Homebrew.Arm64_sonoma; 81 + Sonoma; 82 + X86_64_linux; 83 + Arm64_linux; 84 + Unknown "freebsd_amd64"; 85 + ] 86 + in 87 + List.iter 88 + (fun p -> 89 + let s = Homebrew.platform_to_string p in 90 + Alcotest.(check bool) 91 + (Fmt.str "platform string non-empty: %s" s) 92 + true 93 + (String.length s > 0)) 94 + platforms 95 + 96 + let test_generate_formula () = 97 + let config : Homebrew.config = 98 + { 99 + handle = "test.example"; 100 + mono_url = "https://example.com/mono.git"; 101 + license = "ISC"; 102 + storage = 103 + { 104 + bucket = "test-bottles"; 105 + region = "us-east"; 106 + profile = "test"; 107 + endpoint = "https://s3.us-east.scw.cloud"; 108 + rclone_remote = "scaleway"; 109 + }; 110 + tap = 111 + { 112 + clone_url = "https://example.com/tap.git"; 113 + push_url = "git@example.com:tap"; 114 + local_path = "../tap"; 115 + }; 116 + binaries = []; 117 + build_dir = "_build"; 118 + } 119 + in 120 + let bin : Homebrew.binary = 121 + { 122 + name = "myapp"; 123 + package = "ocaml-myapp"; 124 + description = "My test app"; 125 + homepage = "https://example.com/myapp"; 126 + head_deps = [ { dep_name = "docker"; dep_type = `Recommended } ]; 127 + exe_name = None; 128 + } 129 + in 130 + let formula = Homebrew.generate_formula config bin in 131 + Alcotest.(check bool) "has class" true 132 + (Astring.String.is_infix ~affix:"class Myapp < Formula" formula); 133 + Alcotest.(check bool) "has desc" true 134 + (Astring.String.is_infix ~affix:"desc \"My test app\"" formula); 135 + Alcotest.(check bool) "has homepage" true 136 + (Astring.String.is_infix ~affix:"homepage \"https://example.com/myapp\"" 137 + formula); 138 + Alcotest.(check bool) "has license" true 139 + (Astring.String.is_infix ~affix:"license \"ISC\"" formula); 140 + Alcotest.(check bool) "has arm64" true 141 + (Astring.String.is_infix ~affix:"arm64_sonoma" formula); 142 + Alcotest.(check bool) "has sonoma" true 143 + (Astring.String.is_infix ~affix:"sonoma.bottle" formula); 144 + Alcotest.(check bool) "has linux" true 145 + (Astring.String.is_infix ~affix:"x86_64_linux" formula); 146 + Alcotest.(check bool) "has docker dep" true 147 + (Astring.String.is_infix ~affix:"depends_on \"docker\" => :recommended" 148 + formula); 149 + Alcotest.(check bool) "has head build" true 150 + (Astring.String.is_infix ~affix:"head \"https://example.com/mono.git\"" 151 + formula); 152 + Alcotest.(check bool) "has install" true 153 + (Astring.String.is_infix ~affix:"bin.install \"myapp\"" formula); 154 + Alcotest.(check bool) "has test" true 155 + (Astring.String.is_infix ~affix:"system bin/\"myapp\", \"--help\"" formula) 156 + 157 + let test_generate_formula_kebab_case () = 158 + let config : Homebrew.config = 159 + { 160 + handle = "test.example"; 161 + mono_url = "https://example.com/mono.git"; 162 + license = "ISC"; 163 + storage = 164 + { 165 + bucket = "b"; 166 + region = "r"; 167 + profile = "p"; 168 + endpoint = "e"; 169 + rclone_remote = "s"; 170 + }; 171 + tap = 172 + { 173 + clone_url = "c"; 174 + push_url = "p"; 175 + local_path = "l"; 176 + }; 177 + binaries = []; 178 + build_dir = "_build"; 179 + } 180 + in 181 + let bin : Homebrew.binary = 182 + { 183 + name = "mdns-query"; 184 + package = "ocaml-mdns"; 185 + description = "mDNS query tool"; 186 + homepage = ""; 187 + head_deps = []; 188 + exe_name = None; 189 + } 190 + in 191 + let formula = Homebrew.generate_formula config bin in 192 + Alcotest.(check bool) "kebab to CamelCase" true 193 + (Astring.String.is_infix ~affix:"class MdnsQuery < Formula" formula) 194 + 195 + let test_generate_formula_custom_exe () = 196 + let config : Homebrew.config = 197 + { 198 + handle = "test.example"; 199 + mono_url = "https://example.com/mono.git"; 200 + license = "ISC"; 201 + storage = 202 + { 203 + bucket = "b"; 204 + region = "r"; 205 + profile = "p"; 206 + endpoint = "e"; 207 + rclone_remote = "s"; 208 + }; 209 + tap = 210 + { 211 + clone_url = "c"; 212 + push_url = "p"; 213 + local_path = "l"; 214 + }; 215 + binaries = []; 216 + build_dir = "_build"; 217 + } 218 + in 219 + let bin : Homebrew.binary = 220 + { 221 + name = "agent"; 222 + package = "ocaml-agent"; 223 + description = "Agent"; 224 + homepage = ""; 225 + head_deps = []; 226 + exe_name = Some "main"; 227 + } 228 + in 229 + let formula = Homebrew.generate_formula config bin in 230 + Alcotest.(check bool) "uses custom exe_name for dune build" true 231 + (Astring.String.is_infix ~affix:"\"dune\", \"build\", \"ocaml-agent/bin/main.exe\"" 232 + formula); 233 + Alcotest.(check bool) "installs as binary name" true 234 + (Astring.String.is_infix ~affix:"=> \"agent\"" formula) 235 + 236 + let test_sha256 () = 237 + let tmp = Filename.temp_file "homebrew_test" ".txt" in 238 + let oc = open_out tmp in 239 + output_string oc "hello world\n"; 240 + close_out oc; 241 + match Homebrew.sha256_file tmp with 242 + | Error e -> 243 + Sys.remove tmp; 244 + Alcotest.fail (Fmt.str "sha256 error: %s" e) 245 + | Ok hash -> 246 + Sys.remove tmp; 247 + (* sha256 of "hello world\n" *) 248 + Alcotest.(check string) 249 + "sha256" "a948904f2f0f479b8f8564e9d7e91c9020a382a0c92d8063b96d8e073769dba0" 250 + hash 251 + 252 + let suite = 253 + [ 254 + ( "config", 255 + [ 256 + Alcotest.test_case "load" `Quick test_load_config; 257 + Alcotest.test_case "defaults" `Quick test_defaults; 258 + ] ); 259 + ( "platform", 260 + [ Alcotest.test_case "roundtrip" `Quick test_platform_roundtrip ] ); 261 + ( "formula", 262 + [ 263 + Alcotest.test_case "generate" `Quick test_generate_formula; 264 + Alcotest.test_case "kebab-case" `Quick test_generate_formula_kebab_case; 265 + Alcotest.test_case "custom exe" `Quick test_generate_formula_custom_exe; 266 + ] ); 267 + ("sha256", [ Alcotest.test_case "file" `Quick test_sha256 ]); 268 + ]