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.

xmlt fuzz: restrict generators to XML-safe ASCII

The uutf-based UTF-8 validation added in e41fc608 rejects arbitrary
random bytes. Restrict xml_safe_text/xml_attr_value to ASCII bytes
that are valid UTF-8 single-byte sequences and XML-legal, so codec
roundtrip tests exercise real spec inputs.

+462 -332
+30 -2
README.md
··· 39 39 clone_url: https://tangled.org/gazagnaire.org/homebrew-monopam.git 40 40 push_url: git@git.recoil.org:gazagnaire.org/homebrew-monopam 41 41 42 - binaries: 42 + build: 43 + linux: static # static (Alpine + musl) or linuxbrew (Homebrew on Linux) 44 + 45 + packages: 43 46 - name: prune 44 47 target: prune/bin/main.exe 45 48 description: "Dead code remover for OCaml .mli files" ··· 58 61 type: recommended 59 62 ``` 60 63 61 - ### Binary fields 64 + ### Package fields 62 65 63 66 | Field | Required | Default | Description | 64 67 |-------|----------|---------|-------------| ··· 69 72 | `head_deps` | no | `[]` | Runtime dependencies for `--HEAD` builds | 70 73 | `conflicts_with` | no | `[]` | Homebrew packages that conflict | 71 74 75 + ### Build strategy 76 + 77 + `build.linux` picks how Linux bottles are built: 78 + 79 + - `static` (default) — Alpine + musl container with `-static` linking. Produces 80 + fully static binaries that run on any Linux distro, and can be distributed 81 + outside Homebrew too. Requires pure-OCaml dependencies (no OpenSSL, no 82 + glibc-specific features). 83 + - `linuxbrew` — Homebrew-on-Linux container, dynamic linking against brew's 84 + libs. Only usable via `brew install` on Linux; not portable to non-brew 85 + systems. 86 + 87 + macOS bottles always use the native runner and link dynamically against 88 + `libSystem.dylib` (Apple requirement). 89 + 72 90 ### Optional config fields 73 91 74 92 | Field | Default | ··· 76 94 | `mono_url` | `https://tangled.org/{handle}/mono.git` | 77 95 | `license` | `ISC` | 78 96 | `build_dir` | `_homebrew_build` | 97 + | `build.linux` | `static` | 79 98 | `storage.endpoint` | `https://s3.{region}.scw.cloud` | 80 99 | `storage.rclone_remote` | `scaleway` | 81 100 | `tap.local_path` | `../homebrew-monopam` | 101 + 102 + ### S3 layout 103 + 104 + Uploaded bottles are laid out under the bucket as: 105 + 106 + ``` 107 + {bucket}/{package}/{platform}/{version}.bottle.tar.gz 108 + {bucket}/{package}/{platform}/latest.bottle.tar.gz 109 + ``` 82 110 83 111 ## Usage 84 112
+3 -3
bin/cmd_build.ml
··· 4 4 let cmd = 5 5 let run () config_file names = 6 6 let config = load_config config_file in 7 - let paths = or_die (Homebrew.build config names) in 7 + let paths = or_die (Homebrew.build_packages config names) in 8 8 List.iter (fun p -> Fmt.pr "Built: %s@." p) paths 9 9 in 10 10 let info = ··· 14 14 `S Manpage.s_description; 15 15 `P 16 16 "Builds the monorepo with dune, then packages each configured \ 17 - binary as a Homebrew bottle tarball."; 17 + package as a Homebrew bottle tarball."; 18 18 ] 19 19 in 20 20 Cmd.v info 21 21 Term.( 22 22 const run 23 23 $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 24 - $ config_file $ binary_names) 24 + $ config_file $ package_names)
+17 -11
bin/cmd_config.ml
··· 4 4 let cmd = 5 5 let run () config_file = 6 6 let config = load_config config_file in 7 - Fmt.pr "Handle: %s@." config.handle; 8 - Fmt.pr "License: %s@." config.license; 9 - Fmt.pr "Mono URL: %s@." config.mono_url; 10 - Fmt.pr "Storage: %s (bucket: %s, region: %s)@." config.storage.profile 7 + let linux_mode = 8 + match config.build.linux with 9 + | Homebrew.Static -> "static (Alpine + musl)" 10 + | Homebrew.Linuxbrew -> "linuxbrew (dynamic)" 11 + in 12 + Fmt.pr "Handle: %s@." config.handle; 13 + Fmt.pr "License: %s@." config.license; 14 + Fmt.pr "Mono URL: %s@." config.mono_url; 15 + Fmt.pr "Storage: %s (bucket: %s, region: %s)@." config.storage.profile 11 16 config.storage.bucket config.storage.region; 12 - Fmt.pr "Tap: %s@." config.tap.clone_url; 13 - Fmt.pr "Build dir: %s@." config.build_dir; 14 - Fmt.pr "Platform: %s@." 17 + Fmt.pr "Tap: %s@." config.tap.clone_url; 18 + Fmt.pr "Build dir: %s@." config.build_dir; 19 + Fmt.pr "Linux mode: %s@." linux_mode; 20 + Fmt.pr "Platform: %s@." 15 21 (Homebrew.platform_to_string (Homebrew.detect_platform ())); 16 - Fmt.pr "Binaries:@."; 22 + Fmt.pr "Packages:@."; 17 23 List.iter 18 - (fun (b : Homebrew.binary) -> 19 - Fmt.pr " %-12s %s (%s)@." b.name b.description b.target) 20 - config.binaries 24 + (fun (p : Homebrew.package) -> 25 + Fmt.pr " %-12s %s (%s)@." p.name p.description p.target) 26 + config.packages 21 27 in 22 28 let info = 23 29 Cmd.info "config" ~doc:"Show parsed configuration."
+9 -9
bin/cmd_formula.ml
··· 4 4 let cmd = 5 5 let run () config_file names = 6 6 let config = load_config config_file in 7 - let binaries = 7 + let packages = 8 8 match names with 9 - | [] -> config.binaries 9 + | [] -> config.packages 10 10 | names -> 11 11 List.filter 12 - (fun (b : Homebrew.binary) -> List.mem b.name names) 13 - config.binaries 12 + (fun (p : Homebrew.package) -> List.mem p.name names) 13 + config.packages 14 14 in 15 15 List.iter 16 - (fun bin -> 17 - let formula = Homebrew.generate_formula config bin in 16 + (fun pkg -> 17 + let formula = Homebrew.generate_formula config pkg in 18 18 Fmt.pr "%s" formula) 19 - binaries 19 + packages 20 20 in 21 21 let info = 22 22 Cmd.info "formula" ~doc:"Generate Homebrew Ruby formulas." ··· 24 24 [ 25 25 `S Manpage.s_description; 26 26 `P 27 - "Generates Ruby formula files for the configured binaries. \ 27 + "Generates Ruby formula files for the configured packages. \ 28 28 Formulas include platform-specific bottle URLs and source build \ 29 29 instructions."; 30 30 ] ··· 33 33 Term.( 34 34 const run 35 35 $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 36 - $ config_file $ binary_names) 36 + $ config_file $ package_names)
+9 -9
bin/cmd_release.ml
··· 4 4 let cmd = 5 5 let run () config_file dry_run names = 6 6 let config = load_config config_file in 7 - let binaries = 7 + let packages = 8 8 match names with 9 - | [] -> config.Homebrew.binaries 9 + | [] -> config.Homebrew.packages 10 10 | names -> 11 11 List.filter 12 - (fun (b : Homebrew.binary) -> List.mem b.name names) 13 - config.binaries 12 + (fun (p : Homebrew.package) -> List.mem p.name names) 13 + config.packages 14 14 in 15 15 if dry_run then begin 16 16 Fmt.pr "Would build the following targets:@."; 17 17 List.iter 18 - (fun (bin : Homebrew.binary) -> 19 - Fmt.pr " dune build --profile=release %s@." bin.target) 20 - binaries; 18 + (fun (pkg : Homebrew.package) -> 19 + Fmt.pr " dune build --profile=release %s@." pkg.target) 20 + packages; 21 21 Fmt.pr "@.Packages: %s@." 22 - (String.concat ", " (List.map (fun b -> b.Homebrew.name) binaries)) 22 + (String.concat ", " (List.map (fun p -> p.Homebrew.name) packages)) 23 23 end 24 24 else or_die (Homebrew.release config names) 25 25 in ··· 42 42 Term.( 43 43 const run 44 44 $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 45 - $ config_file $ dry_run $ binary_names) 45 + $ config_file $ dry_run $ package_names)
+17 -19
bin/cmd_update.ml
··· 2 2 3 3 let run config_file names = 4 4 let config = Common.load_config config_file in 5 - let binaries = 5 + let packages = 6 6 match names with 7 - | [] -> config.Homebrew.binaries 7 + | [] -> config.Homebrew.packages 8 8 | names -> 9 9 List.filter 10 - (fun (b : Homebrew.binary) -> List.mem b.name names) 11 - config.binaries 10 + (fun (p : Homebrew.package) -> List.mem p.name names) 11 + config.packages 12 12 in 13 - if binaries = [] then ( 14 - Fmt.epr "No matching binaries found.@."; 13 + if packages = [] then ( 14 + Fmt.epr "No matching packages found.@."; 15 15 exit 1); 16 16 let tap_name = Homebrew.tap_name config in 17 - (* Update the tap *) 18 17 (match Bos.OS.Cmd.run Bos.Cmd.(v "brew" % "update") with 19 18 | Ok () -> () 20 19 | Error _ -> Fmt.epr "Warning: brew update failed@."); 21 - (* Only upgrade binaries that are already installed *) 22 20 let installed = 23 21 List.filter 24 - (fun (b : Homebrew.binary) -> 25 - let formula = Fmt.str "%s/%s" tap_name b.name in 22 + (fun (p : Homebrew.package) -> 23 + let formula = Fmt.str "%s/%s" tap_name p.name in 26 24 match Bos.OS.Cmd.run_status Bos.Cmd.(v "brew" % "list" % formula) with 27 25 | Ok (`Exited 0) -> true 28 26 | _ -> false) 29 - binaries 27 + packages 30 28 in 31 29 if installed = [] then ( 32 - Fmt.pr "No installed binaries to upgrade.@."; 30 + Fmt.pr "No installed packages to upgrade.@."; 33 31 exit 0); 34 32 List.iter 35 - (fun (b : Homebrew.binary) -> 36 - let formula = Fmt.str "%s/%s" tap_name b.name in 37 - Fmt.pr "Upgrading %s...@." b.name; 33 + (fun (p : Homebrew.package) -> 34 + let formula = Fmt.str "%s/%s" tap_name p.name in 35 + Fmt.pr "Upgrading %s...@." p.name; 38 36 match Bos.OS.Cmd.run Bos.Cmd.(v "brew" % "upgrade" % formula) with 39 37 | Ok () -> () 40 - | Error _ -> Fmt.epr " Failed to upgrade %s@." b.name) 38 + | Error _ -> Fmt.epr " Failed to upgrade %s@." p.name) 41 39 installed; 42 - Fmt.pr "Done. %d binaries upgraded.@." (List.length installed) 40 + Fmt.pr "Done. %d packages upgraded.@." (List.length installed) 43 41 44 42 let cmd = 45 - let doc = "Upgrade installed bottled binaries via brew." in 43 + let doc = "Upgrade installed bottled packages via brew." in 46 44 let info = Cmd.info "update" ~doc in 47 - Cmd.v info Term.(const run $ Common.config_file $ Common.binary_names) 45 + Cmd.v info Term.(const run $ Common.config_file $ Common.package_names)
+98 -35
bin/cmd_workflow.ml
··· 7 7 working-directory: mono 8 8 run: | 9 9 set -eo pipefail 10 - if [ "${{ inputs.binary }}" = "all" ]; then 10 + if [ "${{ inputs.package }}" = "all" ]; then 11 11 opam exec -- dune exec -- bottler build 12 12 else 13 - opam exec -- dune exec -- bottler build "${{ inputs.binary }}" 13 + opam exec -- dune exec -- bottler build "${{ inputs.package }}" 14 14 fi 15 15 16 16 - name: Upload bottles to storage 17 17 working-directory: mono 18 18 run: | 19 19 set -eo pipefail 20 - if [ "${{ inputs.binary }}" = "all" ]; then 20 + if [ "${{ inputs.package }}" = "all" ]; then 21 21 opam exec -- dune exec -- bottler upload 22 22 else 23 - opam exec -- dune exec -- bottler upload "${{ inputs.binary }}" 23 + opam exec -- dune exec -- bottler upload "${{ inputs.package }}" 24 24 fi 25 25 env: 26 26 AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }} ··· 32 32 name: checksums-${{ matrix.platform }} 33 33 path: mono/*.sha256|} 34 34 35 - let build_job mono_url = 35 + let opam_cache_step = 36 + {| 37 + - name: Cache opam 38 + uses: actions/cache@v4 39 + with: 40 + path: ~/.opam 41 + key: opam-${{ matrix.platform }}-5.3.0-${{ hashFiles('mono/**/*.opam') }} 42 + restore-keys: opam-${{ matrix.platform }}-5.3.0-|} 43 + 44 + (* Matrix entries and per-platform setup differ by linux mode. *) 45 + 46 + let linux_matrix_static = 47 + {| - os: ubuntu-latest 48 + platform: x86_64_linux 49 + container: ocaml/opam:alpine-3.20-ocaml-5.3|} 50 + 51 + let linux_matrix_linuxbrew = 52 + {| - os: ubuntu-latest 53 + platform: x86_64_linux 54 + container: ghcr.io/homebrew/ubuntu22.04:latest|} 55 + 56 + let linux_setup_static = 57 + {| - name: Install static build deps (Alpine) 58 + if: runner.os == 'Linux' 59 + run: | 60 + set -eo pipefail 61 + sudo apk add --no-cache gmp-dev gmp-static libffi-dev zlib-dev \ 62 + zlib-static musl-dev m4 pkgconfig bash curl tar 63 + 64 + - name: Force static linking 65 + if: runner.os == 'Linux' 66 + run: | 67 + echo "OCAMLPARAM=_,ccopt=-static" >> "$GITHUB_ENV"|} 68 + 69 + let linux_setup_linuxbrew = 70 + {| - name: Install build deps (Linuxbrew) 71 + if: runner.os == 'Linux' 72 + run: brew install opam gmp libffi pkg-config 73 + 74 + - name: Point compiler at Linuxbrew libs 75 + if: runner.os == 'Linux' 76 + run: | 77 + BREW_PREFIX=/home/linuxbrew/.linuxbrew 78 + { 79 + echo "LIBRARY_PATH=$BREW_PREFIX/lib" 80 + echo "LD_LIBRARY_PATH=$BREW_PREFIX/lib" 81 + echo "C_INCLUDE_PATH=$BREW_PREFIX/include" 82 + echo "CPLUS_INCLUDE_PATH=$BREW_PREFIX/include" 83 + echo "PKG_CONFIG_PATH=$BREW_PREFIX/lib/pkgconfig" 84 + } >> "$GITHUB_ENV"|} 85 + 86 + let build_job config = 87 + let linux_matrix, linux_setup = 88 + match config.Homebrew.build.linux with 89 + | Homebrew.Static -> (linux_matrix_static, linux_setup_static) 90 + | Homebrew.Linuxbrew -> (linux_matrix_linuxbrew, linux_setup_linuxbrew) 91 + in 36 92 Fmt.str 37 93 {| build: 38 94 strategy: ··· 40 96 include: 41 97 - os: macos-14 42 98 platform: arm64_sonoma 43 - - os: ubuntu-latest 44 - platform: x86_64_linux 99 + container: '' 100 + %s 45 101 runs-on: ${{ matrix.os }} 102 + container: ${{ matrix.container }} 46 103 steps: 47 104 - name: Checkout monorepo 48 105 run: git clone --depth 1 %s mono 49 106 50 - - name: Install opam (macOS) 107 + - name: Install build deps (macOS) 51 108 if: runner.os == 'macOS' 52 109 run: brew install opam pkg-config gmp libffi 53 110 54 - - name: Install opam (Linux) 55 - if: runner.os == 'Linux' 56 - run: | 57 - set -eo pipefail 58 - sudo apt-get update 59 - sudo apt-get install -y opam libgmp-dev libffi-dev pkg-config 111 + %s 112 + %s 60 113 61 114 - name: Setup OCaml 62 115 run: | ··· 71 124 - name: Build bottler 72 125 working-directory: mono 73 126 run: opam exec -- dune build --release ocaml-homebrew/bin/main.exe%s|} 74 - mono_url bottle_steps 127 + linux_matrix config.Homebrew.mono_url linux_setup opam_cache_step 128 + bottle_steps 75 129 76 - let formula_update_steps binary_names = 130 + let formula_update_steps package_names = 77 131 Fmt.str 78 132 {| 79 133 - name: Update formulas with checksums ··· 81 135 set -eo pipefail 82 136 mkdir -p Formula 83 137 cd mono 84 - if [ "${{ inputs.binary }}" = "all" ]; then 138 + if [ "${{ inputs.package }}" = "all" ]; then 85 139 for name in %s; do 86 140 opam exec -- dune exec -- bottler formula "$name" > ../Formula/"$name".rb 87 141 done 88 142 else 89 - opam exec -- dune exec -- bottler formula "${{ inputs.binary }}" > "../Formula/${{ inputs.binary }}.rb" 143 + opam exec -- dune exec -- bottler formula "${{ inputs.package }}" > "../Formula/${{ inputs.package }}.rb" 90 144 fi 91 145 92 146 - name: Commit and push ··· 95 149 git config user.name "GitHub Actions" 96 150 git config user.email "actions@github.com" 97 151 git add Formula/ 98 - git diff --staged --quiet || git commit -m "Update bottles for ${{ inputs.binary }}" 152 + git diff --staged --quiet || git commit -m "Update bottles for ${{ inputs.package }}" 99 153 git push origin HEAD|} 100 - binary_names 154 + package_names 101 155 102 - let update_tap_job mono_url binary_names = 156 + let update_tap_job mono_url package_names = 103 157 Fmt.str 104 158 {| update-tap: 105 159 needs: build ··· 121 175 sudo apt-get update 122 176 sudo apt-get install -y opam libgmp-dev libffi-dev pkg-config 123 177 178 + - name: Cache opam 179 + uses: actions/cache@v4 180 + with: 181 + path: ~/.opam 182 + key: opam-tap-5.3.0-${{ hashFiles('mono/**/*.opam') }} 183 + restore-keys: opam-tap-5.3.0- 184 + 124 185 - name: Setup OCaml 125 186 run: | 126 187 set -eo pipefail ··· 135 196 working-directory: mono 136 197 run: opam exec -- dune build --release ocaml-homebrew/bin/main.exe%s|} 137 198 mono_url 138 - (formula_update_steps binary_names) 199 + (formula_update_steps package_names) 139 200 140 201 let generate_workflow (config : Homebrew.config) = 141 - let binaries_list = 142 - config.binaries |> List.map (fun b -> b.Homebrew.name) |> String.concat ", " 202 + let packages_list = 203 + config.packages |> List.map (fun p -> p.Homebrew.name) |> String.concat ", " 143 204 in 144 - let binary_options = 145 - config.binaries 146 - |> List.map (fun b -> Fmt.str " - %s" b.Homebrew.name) 205 + let package_options = 206 + config.packages 207 + |> List.map (fun p -> Fmt.str " - %s" p.Homebrew.name) 147 208 |> String.concat "\n" 148 209 in 149 - let binary_names = 150 - config.binaries |> List.map (fun b -> b.Homebrew.name) |> String.concat " " 210 + let package_names = 211 + config.packages |> List.map (fun p -> p.Homebrew.name) |> String.concat " " 151 212 in 152 213 Fmt.str 153 214 {|name: Release Bottles ··· 155 216 on: 156 217 workflow_dispatch: 157 218 inputs: 158 - binary: 159 - description: 'Binary to release (%s)' 219 + package: 220 + description: 'Package to release (%s)' 160 221 required: true 161 222 default: 'all' 162 223 type: choice ··· 169 230 170 231 %s 171 232 |} 172 - binaries_list binary_options 173 - (build_job config.mono_url) 174 - (update_tap_job config.mono_url binary_names) 233 + packages_list package_options (build_job config) 234 + (update_tap_job config.mono_url package_names) 175 235 176 236 let cmd = 177 237 let run () config_file output = ··· 200 260 `P 201 261 "Generates a GitHub Actions workflow file for building and \ 202 262 releasing Homebrew bottles across multiple platforms (macOS \ 203 - ARM64, macOS x86_64, Linux)."; 263 + ARM64, Linux x86_64). Linux strategy is controlled by \ 264 + [build.linux] in homebrew.yml: [static] produces Alpine+musl \ 265 + static binaries, [linuxbrew] produces dynamically-linked \ 266 + Homebrew-on-Linux bottles."; 204 267 `S Manpage.s_examples; 205 268 `P "Generate workflow to stdout:"; 206 269 `Pre "bottler workflow";
+2 -2
bin/common.ml
··· 9 9 Arg.( 10 10 value & opt string "homebrew.yml" & info [ "c"; "config" ] ~docv:"FILE" ~doc) 11 11 12 - let binary_names = 13 - let doc = "Binary names to operate on (all if omitted)." in 12 + let package_names = 13 + let doc = "Package names to operate on (all if omitted)." in 14 14 Arg.(value & pos_all string [] & info [] ~docv:"NAME" ~doc) 15 15 16 16 let load_config file =
+7 -8
fuzz/fuzz_homebrew.ml
··· 5 5 6 6 open Alcobar 7 7 8 - (* Fuzz the YAML config parser: it should never raise *) 9 8 let test_yaml_parse input = 10 9 let _ = Yamlt.decode_string Homebrew.config_jsont input in 11 10 () 12 11 13 - (* Fuzz formula generation with arbitrary binary configs *) 14 - let gen_binary = 12 + let gen_package = 15 13 map [ bytes; bytes ] (fun name description -> 16 14 let name = 17 15 if String.length name = 0 then "x" ··· 28 26 head_deps = []; 29 27 conflicts_with = []; 30 28 } 31 - : Homebrew.binary)) 29 + : Homebrew.package)) 32 30 33 - let test_formula_gen (bin : Homebrew.binary) = 31 + let test_formula_gen (pkg : Homebrew.package) = 34 32 let config : Homebrew.config = 35 33 { 36 34 handle = "test"; ··· 45 43 rclone_remote = "s"; 46 44 }; 47 45 tap = { clone_url = "c"; push_url = "p"; local_path = "l" }; 48 - binaries = []; 46 + build = { linux = Homebrew.Static }; 47 + packages = []; 49 48 build_dir = "_build"; 50 49 } 51 50 in 52 - let formula = Homebrew.generate_formula config bin in 51 + let formula = Homebrew.generate_formula config pkg in 53 52 check (String.length formula > 0) 54 53 55 54 let suite = 56 55 ( "homebrew", 57 56 [ 58 57 test_case "yaml parse no crash" [ bytes ] test_yaml_parse; 59 - test_case "formula gen no crash" [ gen_binary ] test_formula_gen; 58 + test_case "formula gen no crash" [ gen_package ] test_formula_gen; 60 59 ] )
+169 -138
lib/homebrew.ml
··· 22 22 23 23 type tap = { clone_url : string; push_url : string; local_path : string } 24 24 type head_dep = { dep_name : string; dep_type : [ `Build | `Recommended ] } 25 + type linux_mode = Static | Linuxbrew 26 + type build = { linux : linux_mode } 25 27 26 - type binary = { 28 + type package = { 27 29 name : string; 28 30 target : string; 29 31 description : string; ··· 38 40 license : string; 39 41 storage : storage; 40 42 tap : tap; 41 - binaries : binary list; 43 + build : build; 44 + packages : package list; 42 45 build_dir : string; 43 46 } 44 47 ··· 69 72 d.dep_type) 70 73 |> Jsont.Object.finish 71 74 72 - let binary_jsont : binary Jsont.t = 73 - Jsont.Object.map ~kind:"binary" 75 + let linux_mode_jsont : linux_mode Jsont.t = 76 + let dec = function 77 + | "static" -> Static 78 + | "linuxbrew" -> Linuxbrew 79 + | s -> Fmt.invalid_arg "unknown linux build mode: %s" s 80 + in 81 + let enc = function Static -> "static" | Linuxbrew -> "linuxbrew" in 82 + Jsont.map ~dec ~enc Jsont.string 83 + 84 + let build_jsont : build Jsont.t = 85 + Jsont.Object.map ~kind:"build" (fun linux -> { linux }) 86 + |> Jsont.Object.mem "linux" linux_mode_jsont ~dec_absent:Static 87 + ~enc:(fun b -> b.linux) 88 + |> Jsont.Object.finish 89 + 90 + let package_jsont : package Jsont.t = 91 + Jsont.Object.map ~kind:"package" 74 92 (fun name target description homepage head_deps conflicts_with -> 75 93 { name; target; description; homepage; head_deps; conflicts_with }) 76 - |> Jsont.Object.mem "name" Jsont.string ~enc:(fun b -> b.name) 77 - |> Jsont.Object.mem "target" Jsont.string ~enc:(fun b -> b.target) 78 - |> Jsont.Object.mem "description" Jsont.string ~enc:(fun b -> b.description) 79 - |> Jsont.Object.mem "homepage" Jsont.string ~dec_absent:"" ~enc:(fun b -> 80 - b.homepage) 94 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun p -> p.name) 95 + |> Jsont.Object.mem "target" Jsont.string ~enc:(fun p -> p.target) 96 + |> Jsont.Object.mem "description" Jsont.string ~enc:(fun p -> p.description) 97 + |> Jsont.Object.mem "homepage" Jsont.string ~dec_absent:"" ~enc:(fun p -> 98 + p.homepage) 81 99 |> Jsont.Object.mem "head_deps" (Jsont.list head_dep_jsont) ~dec_absent:[] 82 - ~enc:(fun b -> b.head_deps) 100 + ~enc:(fun p -> p.head_deps) 83 101 |> Jsont.Object.mem "conflicts_with" (Jsont.list Jsont.string) ~dec_absent:[] 84 - ~enc:(fun b -> b.conflicts_with) 102 + ~enc:(fun p -> p.conflicts_with) 85 103 |> Jsont.Object.finish 86 104 87 105 let storage_jsont : storage Jsont.t = ··· 127 145 128 146 let config_jsont : config Jsont.t = 129 147 Jsont.Object.map ~kind:"config" 130 - (fun handle mono_url license storage tap binaries build_dir -> 148 + (fun handle mono_url license storage tap build packages build_dir -> 131 149 let mono_url = 132 150 match mono_url with 133 151 | Some u -> u ··· 137 155 let build_dir = 138 156 match build_dir with Some d -> d | None -> "_homebrew_build" 139 157 in 140 - { handle; mono_url; license; storage; tap; binaries; build_dir }) 158 + { handle; mono_url; license; storage; tap; build; packages; build_dir }) 141 159 |> Jsont.Object.mem "handle" Jsont.string ~enc:(fun c -> c.handle) 142 160 |> Jsont.Object.opt_mem "mono_url" Jsont.string ~enc:(fun c -> 143 161 Some c.mono_url) 144 162 |> Jsont.Object.opt_mem "license" Jsont.string ~enc:(fun c -> Some c.license) 145 163 |> Jsont.Object.mem "storage" storage_jsont ~enc:(fun c -> c.storage) 146 164 |> Jsont.Object.mem "tap" tap_jsont ~enc:(fun c -> c.tap) 147 - |> Jsont.Object.mem "binaries" (Jsont.list binary_jsont) ~enc:(fun c -> 148 - c.binaries) 165 + |> Jsont.Object.mem "build" build_jsont ~dec_absent:{ linux = Static } 166 + ~enc:(fun c -> c.build) 167 + |> Jsont.Object.mem "packages" (Jsont.list package_jsont) ~enc:(fun c -> 168 + c.packages) 149 169 |> Jsont.Object.opt_mem "build_dir" Jsont.string ~enc:(fun c -> 150 170 Some c.build_dir) 151 171 |> Jsont.Object.finish ··· 157 177 match Yamlt.decode_string config_jsont content with 158 178 | Ok config -> 159 179 Log.info (fun m -> 160 - m "Loaded config: %d binaries" (List.length config.binaries)); 180 + m "Loaded config: %d packages" (List.length config.packages)); 161 181 Ok config 162 182 | Error e -> err_parse_config e) 163 183 ··· 204 224 (* {1 Building} *) 205 225 206 226 let git_head_short () = 207 - (* Read HEAD directly from .git without shelling out. *) 208 227 let read_file path = 209 228 match Bos.OS.File.read (Fpath.v path) with 210 229 | Ok s -> Some (String.trim s) ··· 227 246 let hash = git_head_short () in 228 247 Fmt.str "%s-%s" date hash 229 248 230 - let exe config (bin : binary) = 249 + let exe config (pkg : package) = 231 250 ignore config; 232 - let path = Fmt.str "_build/default/%s" bin.target in 251 + let path = Fmt.str "_build/default/%s" pkg.target in 233 252 let fpath = Fpath.v path in 234 253 if Bos.OS.File.exists fpath = Ok true then Some path else None 235 254 236 - let build_one config (bin : binary) platform version = 255 + let build_one config (pkg : package) platform version = 237 256 let platform_str = platform_to_string platform in 238 257 let bottle_name = 239 - Fmt.str "%s-%s.%s.bottle.tar.gz" bin.name version platform_str 258 + Fmt.str "%s-%s.%s.bottle.tar.gz" pkg.name version platform_str 240 259 in 241 260 let bottle_dir = 242 - Fpath.(v config.build_dir / Fmt.str "%s-%s" bin.name version) 261 + Fpath.(v config.build_dir / Fmt.str "%s-%s" pkg.name version) 243 262 in 244 - match exe config bin with 263 + match exe config pkg with 245 264 | None -> 246 - Log.warn (fun m -> m "Executable not found for %s, skipping" bin.name); 265 + Log.warn (fun m -> m "Executable not found for %s, skipping" pkg.name); 247 266 Ok None 248 267 | Some exe -> 249 268 let open Result in ··· 253 272 in 254 273 let* _ = 255 274 Bos.OS.Cmd.run 256 - Bos.Cmd.(v "cp" % exe % Fpath.to_string Fpath.(bottle_dir / bin.name)) 275 + Bos.Cmd.(v "cp" % exe % Fpath.to_string Fpath.(bottle_dir / pkg.name)) 257 276 |> Result.map_error (fun (`Msg e) -> e) 258 277 in 259 278 let* _ = 260 279 Bos.OS.Cmd.run 261 280 Bos.Cmd.( 262 - v "chmod" % "+x" % Fpath.to_string Fpath.(bottle_dir / bin.name)) 281 + v "chmod" % "+x" % Fpath.to_string Fpath.(bottle_dir / pkg.name)) 263 282 |> Result.map_error (fun (`Msg e) -> e) 264 283 in 265 284 let bottle_path = Fpath.(v config.build_dir / bottle_name) in ··· 269 288 v "tar" % "czf" 270 289 % Fpath.to_string bottle_path 271 290 % "-C" % config.build_dir 272 - % Fmt.str "%s-%s" bin.name version) 291 + % Fmt.str "%s-%s" pkg.name version) 273 292 |> Result.map_error (fun (`Msg e) -> e) 274 293 in 275 294 let* _ = ··· 279 298 Log.info (fun m -> m "Built: %s" (Fpath.to_string bottle_path)); 280 299 Ok (Some (Fpath.to_string bottle_path)) 281 300 282 - let build config names = 301 + let build_packages config names = 283 302 let open Result in 284 303 let ( let* ) = bind in 285 304 let platform = detect_platform () in ··· 290 309 Bos.OS.Dir.create (Fpath.v config.build_dir) 291 310 |> Result.map_error (fun (`Msg e) -> e) 292 311 in 293 - let binaries = 312 + let packages = 294 313 match names with 295 - | [] -> config.binaries 314 + | [] -> config.packages 296 315 | names -> 297 - List.filter (fun (b : binary) -> List.mem b.name names) config.binaries 316 + List.filter (fun (p : package) -> List.mem p.name names) config.packages 298 317 in 299 - (* Build only the specific executables needed *) 300 - let exe_targets = List.map (fun (bin : binary) -> bin.target) binaries in 318 + let exe_targets = List.map (fun (pkg : package) -> pkg.target) packages in 301 319 let* _ = 302 320 Bos.OS.Cmd.run 303 321 (List.fold_left Bos.Cmd.( % ) ··· 308 326 in 309 327 let results = 310 328 List.filter_map 311 - (fun bin -> 312 - match build_one config bin platform version with 329 + (fun pkg -> 330 + match build_one config pkg platform version with 313 331 | Ok (Some path) -> Some (Ok path) 314 332 | Ok None -> None 315 333 | Error e -> Some (Error e)) 316 - binaries 334 + packages 317 335 in 318 336 let errors, paths = 319 337 List.fold_left ··· 327 345 328 346 (* {1 Uploading} *) 329 347 330 - let bottle_url config = 348 + (* S3 layout: bucket/{package}/{platform}/{version}.bottle.tar.gz 349 + bucket/{package}/{platform}/latest.bottle.tar.gz *) 350 + 351 + let bottle_base_url config = 331 352 Fmt.str "https://%s.s3.%s.scw.cloud" config.storage.bucket 332 353 config.storage.region 333 354 355 + let bottle_url_for config ~package ~platform = 356 + Fmt.str "%s/%s/%s/latest.bottle.tar.gz" (bottle_base_url config) package 357 + (platform_to_string platform) 358 + 359 + (* Parse a local bottle filename of shape: 360 + <name>-<version>.<platform>.bottle.tar.gz 361 + where <name> may contain hyphens and <version> starts with YYYYMMDD. 362 + Returns (name, version, platform_str). *) 363 + let parse_bottle_name filename = 364 + let base = Filename.basename filename in 365 + let rec find_version_sep i = 366 + match Astring.String.find_sub ~start:i ~sub:"-" base with 367 + | None -> None 368 + | Some pos -> 369 + if 370 + pos + 1 < String.length base 371 + && base.[pos + 1] >= '0' 372 + && base.[pos + 1] <= '9' 373 + then 374 + let name = String.sub base 0 pos in 375 + let rest = String.sub base (pos + 1) (String.length base - pos - 1) in 376 + Some (name, rest) 377 + else find_version_sep (pos + 1) 378 + in 379 + match find_version_sep 0 with 380 + | None -> None 381 + | Some (name, rest) -> ( 382 + (* rest = <version>.<platform>.bottle.tar.gz *) 383 + match Astring.String.cut ~rev:true ~sep:".bottle.tar.gz" rest with 384 + | None -> None 385 + | Some (version_platform, _) -> ( 386 + match Astring.String.cut ~rev:true ~sep:"." version_platform with 387 + | None -> None 388 + | Some (version, platform_str) -> Some (name, version, platform_str))) 389 + 334 390 let stage_file staging_dir file = 335 391 let open Result in 336 392 let ( let* ) = bind in 337 - let file_base = Filename.basename file in 338 - let name, suffix = 339 - match Astring.String.cut ~rev:true ~sep:"-" file_base with 340 - | Some (n, rest) -> ( 341 - match Astring.String.cut ~sep:"." rest with 342 - | Some (_, platform_rest) -> (n, platform_rest) 343 - | None -> (n, rest)) 344 - | None -> (file_base, "") 345 - in 346 - let latest_name = Fmt.str "%s-latest.%s" name suffix in 347 - let* _ = 348 - Bos.OS.Cmd.run 349 - Bos.Cmd.(v "cp" % file % Fpath.to_string Fpath.(staging_dir / file_base)) 350 - |> Result.map_error (fun (`Msg e) -> e) 351 - in 352 - let* _ = 353 - Bos.OS.Cmd.run 354 - Bos.Cmd.( 355 - v "cp" % file % Fpath.to_string Fpath.(staging_dir / latest_name)) 356 - |> Result.map_error (fun (`Msg e) -> e) 357 - in 358 - Log.info (fun m -> m "Staging: %s + %s" file_base latest_name); 359 - Ok () 393 + match parse_bottle_name file with 394 + | None -> 395 + Log.warn (fun m -> m "Skipping unparseable bottle name: %s" file); 396 + Ok () 397 + | Some (name, version, platform_str) -> 398 + let subdir = Fpath.(staging_dir / name / platform_str) in 399 + let* _ = 400 + Bos.OS.Dir.create subdir |> Result.map_error (fun (`Msg e) -> e) 401 + in 402 + let versioned = Fmt.str "%s.bottle.tar.gz" version in 403 + let latest = "latest.bottle.tar.gz" in 404 + let* _ = 405 + Bos.OS.Cmd.run 406 + Bos.Cmd.(v "cp" % file % Fpath.to_string Fpath.(subdir / versioned)) 407 + |> Result.map_error (fun (`Msg e) -> e) 408 + in 409 + let* _ = 410 + Bos.OS.Cmd.run 411 + Bos.Cmd.(v "cp" % file % Fpath.to_string Fpath.(subdir / latest)) 412 + |> Result.map_error (fun (`Msg e) -> e) 413 + in 414 + Log.info (fun m -> m "Staging: %s/%s/{%s,%s}" name platform_str versioned latest); 415 + Ok () 360 416 361 417 let discover_bottles build_dir = function 362 418 | [] -> ( ··· 401 457 % Fmt.str "%s:%s" remote bucket) 402 458 |> Result.map_error (fun (`Msg e) -> e) 403 459 in 404 - (* Clean up staging *) 405 460 let* _ = 406 461 Bos.OS.Dir.delete ~recurse:true staging_dir 407 462 |> Result.map_error (fun (`Msg e) -> e) 408 463 in 409 464 List.iter 410 465 (fun file -> 411 - Log.info (fun m -> 412 - m "URL: %s/%s" (bottle_url config) (Filename.basename file))) 466 + match parse_bottle_name file with 467 + | Some (name, _, platform_str) -> 468 + Log.info (fun m -> 469 + m "URL: %s/%s/%s/latest.bottle.tar.gz" 470 + (bottle_base_url config) name platform_str) 471 + | None -> ()) 413 472 files; 414 473 Ok () 415 474 ··· 421 480 let first = Char.uppercase_ascii s.[0] in 422 481 String.make 1 first ^ String.sub s 1 (String.length s - 1) 423 482 424 - (* Convert kebab-case name to CamelCase class name *) 425 483 let class_name name = 426 484 let parts = Astring.String.cuts ~sep:"-" name in 427 485 String.concat "" (List.map capitalize_first parts) 428 486 429 - let formula_bottle_urls buf ~url (bin : binary) = 487 + let formula_bottle_urls buf config (pkg : package) = 430 488 let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 489 + let url p = bottle_url_for config ~package:pkg.name ~platform:p in 431 490 pr " on_macos do\n"; 432 491 pr " on_arm do\n"; 433 - pr " url \"%s/%s-latest.arm64_sonoma.bottle.tar.gz\"\n" url bin.name; 492 + pr " url \"%s\"\n" (url Arm64_sonoma); 434 493 pr " sha256 :no_check\n"; 435 494 pr " end\n"; 436 495 pr " on_intel do\n"; 437 - pr " url \"%s/%s-latest.sonoma.bottle.tar.gz\"\n" url bin.name; 496 + pr " url \"%s\"\n" (url Sonoma); 438 497 pr " sha256 :no_check\n"; 439 498 pr " end\n"; 440 499 pr " end\n"; 441 500 pr "\n"; 442 501 pr " on_linux do\n"; 443 - pr " url \"%s/%s-latest.x86_64_linux.bottle.tar.gz\"\n" url bin.name; 502 + pr " url \"%s\"\n" (url X86_64_linux); 444 503 pr " sha256 :no_check\n"; 445 504 pr " end\n" 446 505 447 - let formula_head_section buf config (bin : binary) = 506 + let formula_head_section buf config (pkg : package) = 448 507 let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 449 508 pr " head \"%s\", branch: \"main\"\n" config.mono_url; 450 509 pr "\n"; ··· 460 519 | `Recommended -> ":recommended" 461 520 in 462 521 pr " depends_on \"%s\" => %s\n" dep.dep_name typ) 463 - bin.head_deps; 522 + pkg.head_deps; 464 523 pr " end\n" 465 524 466 - let formula_install_section buf (bin : binary) = 525 + let formula_install_section buf (pkg : package) = 467 526 let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 468 527 pr " def install\n"; 469 528 pr " if build.head?\n"; ··· 474 533 " system \"opam\", \"install\", \".\", \"--deps-only\", \ 475 534 \"--with-test=false\", \"-y\", \"--working-dir\"\n"; 476 535 pr " system \"opam\", \"exec\", \"--\", \"dune\", \"build\", \"%s\"\n" 477 - bin.target; 478 - pr " bin.install \"_build/default/%s\" => \"%s\"\n" bin.target bin.name; 536 + pkg.target; 537 + pr " bin.install \"_build/default/%s\" => \"%s\"\n" pkg.target pkg.name; 479 538 pr " else\n"; 480 - pr " bin.install \"%s\"\n" bin.name; 539 + pr " bin.install \"%s\"\n" pkg.name; 481 540 pr " end\n"; 482 541 pr " end\n" 483 542 484 - let generate_formula config (bin : binary) = 485 - let url = bottle_url config in 543 + let generate_formula config (pkg : package) = 486 544 let homepage = 487 - if bin.homepage <> "" then bin.homepage 488 - else Fmt.str "https://tangled.org/%s/%s" config.handle bin.name 545 + if pkg.homepage <> "" then pkg.homepage 546 + else Fmt.str "https://tangled.org/%s/%s" config.handle pkg.name 489 547 in 490 548 let buf = Buffer.create 1024 in 491 549 let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 492 - pr "class %s < Formula\n" (class_name bin.name); 493 - pr " desc \"%s\"\n" bin.description; 550 + pr "class %s < Formula\n" (class_name pkg.name); 551 + pr " desc \"%s\"\n" pkg.description; 494 552 pr " homepage \"%s\"\n" homepage; 495 553 pr " license \"%s\"\n" config.license; 496 554 pr " version \"latest\"\n"; 497 555 List.iter 498 556 (fun formula -> 499 557 pr " conflicts_with \"%s\", because: \"both install a `%s` binary\"\n" 500 - formula bin.name) 501 - bin.conflicts_with; 558 + formula pkg.name) 559 + pkg.conflicts_with; 502 560 pr "\n"; 503 - formula_bottle_urls buf ~url bin; 561 + formula_bottle_urls buf config pkg; 504 562 pr "\n"; 505 - formula_head_section buf config bin; 563 + formula_head_section buf config pkg; 506 564 pr "\n"; 507 - formula_install_section buf bin; 565 + formula_install_section buf pkg; 508 566 pr "\n"; 509 567 pr " test do\n"; 510 - pr " system bin/\"%s\", \"--help\"\n" bin.name; 568 + pr " system bin/\"%s\", \"--help\"\n" pkg.name; 511 569 pr " end\n"; 512 570 pr "end\n"; 513 571 Buffer.contents buf ··· 548 606 pr "| Formula | Description |\n"; 549 607 pr "|---------|-------------|\n"; 550 608 List.iter 551 - (fun (b : binary) -> pr "| `%s` | %s |\n" b.name b.description) 552 - config.binaries; 609 + (fun (p : package) -> pr "| `%s` | %s |\n" p.name p.description) 610 + config.packages; 553 611 pr "\n## Usage\n\n"; 554 612 pr "```bash\n"; 555 613 pr "# Install pre-built binaries\n"; 556 614 pr "brew install %s\n" 557 - (String.concat " " (List.map (fun (b : binary) -> b.name) config.binaries)); 615 + (String.concat " " (List.map (fun (p : package) -> p.name) config.packages)); 558 616 pr "\n# Or build from source\n"; 559 617 pr "brew install --HEAD %s\n" 560 - (match config.binaries with b :: _ -> b.name | [] -> "NAME"); 618 + (match config.packages with p :: _ -> p.name | [] -> "NAME"); 561 619 pr "```\n\n"; 562 620 pr "## License\n\n"; 563 621 pr "%s\n" config.license; ··· 608 666 let update_formula_checksums config tap_dir checksums = 609 667 let open Result in 610 668 let ( let* ) = bind in 611 - let url = bottle_url config in 612 669 let update_one (name, sha256, platform) = 613 670 let formula_path = Fpath.(v tap_dir / "Formula" / (name ^ ".rb")) in 614 671 match Bos.OS.File.read formula_path with ··· 622 679 (String.sub sha256 0 (min 16 (String.length sha256)))); 623 680 let version = version_string () in 624 681 let new_url = 625 - Fmt.str "%s/%s-%s.%s.bottle.tar.gz" url name version platform_str 682 + Fmt.str "%s/%s/%s/%s.bottle.tar.gz" (bottle_base_url config) name 683 + platform_str version 626 684 in 627 685 let marker = 628 686 match platform with ··· 667 725 let tap_path = Fpath.v config.tap.local_path in 668 726 if Bos.OS.Dir.exists tap_path = Ok true then ( 669 727 Log.info (fun m -> m "Pulling tap..."); 670 - (* Check if remote has any commits before pulling — empty repos have no 671 - branch to pull from and git pull --ff-only fails. *) 672 728 let has_remote_commits = 673 729 Bos.OS.Cmd.run_status 674 730 Bos.Cmd.( ··· 744 800 result 745 801 746 802 let bottle_checksums bottles = 747 - (* Bottle filename: <name>-<version>.<platform>.bottle.tar.gz 748 - Extract name by cutting on the first "-" that is followed by a digit 749 - (the version starts with YYYYMMDD). *) 750 803 List.filter_map 751 804 (fun path -> 752 - let basename = Filename.basename path in 753 - let name, rest = 754 - let rec find_version_sep i = 755 - match Astring.String.find_sub ~start:i ~sub:"-" basename with 756 - | None -> (basename, "") 757 - | Some pos -> 758 - if 759 - pos + 1 < String.length basename 760 - && basename.[pos + 1] >= '0' 761 - && basename.[pos + 1] <= '9' 762 - then 763 - ( String.sub basename 0 pos, 764 - String.sub basename (pos + 1) 765 - (String.length basename - pos - 1) ) 766 - else find_version_sep (pos + 1) 767 - in 768 - find_version_sep 0 769 - in 770 - let platform_str = 771 - match Astring.String.cut ~sep:"." rest with 772 - | Some (_, r) -> ( 773 - match Astring.String.cut ~rev:true ~sep:".bottle" r with 774 - | Some (p, _) -> p 775 - | None -> r) 776 - | None -> "" 777 - in 778 - match sha256_file path with 779 - | Ok sha -> Some (name, sha, platform_of_string platform_str) 780 - | Error e -> 781 - Log.warn (fun m -> m "SHA256 failed for %s: %s" path e); 782 - None) 805 + match parse_bottle_name path with 806 + | None -> None 807 + | Some (name, _version, platform_str) -> ( 808 + match sha256_file path with 809 + | Ok sha -> Some (name, sha, platform_of_string platform_str) 810 + | Error e -> 811 + Log.warn (fun m -> m "SHA256 failed for %s: %s" path e); 812 + None)) 783 813 bottles 784 814 785 815 let ensure_formulas config = ··· 790 820 Bos.OS.Dir.create formula_dir |> Result.map_error (fun (`Msg e) -> e) 791 821 in 792 822 List.iter 793 - (fun (bin : binary) -> 794 - let formula_path = Fpath.(formula_dir / (bin.name ^ ".rb")) in 823 + (fun (pkg : package) -> 824 + let formula_path = Fpath.(formula_dir / (pkg.name ^ ".rb")) in 795 825 if Bos.OS.File.exists formula_path <> Ok true then ( 796 - Log.info (fun m -> m "Generating formula for %s" bin.name); 797 - let content = generate_formula config bin in 826 + Log.info (fun m -> m "Generating formula for %s" pkg.name); 827 + let content = generate_formula config pkg in 798 828 ignore (Bos.OS.File.write formula_path content))) 799 - config.binaries; 829 + config.packages; 800 830 Ok () 801 831 802 832 let release config names = 803 833 let open Result in 804 834 let ( let* ) = bind in 805 - let* bottles = timed "Step 1: Building" (fun () -> build config names) in 835 + let* bottles = 836 + timed "Step 1: Building" (fun () -> build_packages config names) 837 + in 806 838 let* () = timed "Step 2: Uploading" (fun () -> upload config bottles) in 807 839 let* () = 808 840 timed "Step 3: Updating tap" (fun () -> ··· 819 851 in 820 852 commit_and_push_tap config) 821 853 in 822 - Log.app (fun m -> m "Release complete. Bottles at: %s" (bottle_url config)); 854 + Log.app (fun m -> 855 + m "Release complete. Bottles at: %s" (bottle_base_url config)); 823 856 Ok () 824 857 825 858 (* {1 Credential Setup} *) ··· 875 908 Log.app (fun m -> m "Checking storage..."); 876 909 Log.app (fun m -> 877 910 m " Bucket: %s (region: %s)" config.storage.bucket config.storage.region); 878 - Log.app (fun m -> m " URL: %s" (bottle_url config)); 911 + Log.app (fun m -> m " URL: %s" (bottle_base_url config)); 879 912 Log.app (fun m -> m "Checking tap..."); 880 913 let tap_exists = 881 914 Bos.OS.Dir.exists (Fpath.v config.tap.local_path) = Ok true ··· 916 949 let* _rclone = check_cmd "rclone" in 917 950 let profile = config.storage.profile in 918 951 let* () = ensure_scw_profile profile in 919 - (* Generate rclone config from scw profile *) 920 952 Log.app (fun m -> m "Generating rclone configuration..."); 921 953 let* _ = 922 954 Bos.OS.Cmd.run ··· 925 957 % profile) 926 958 |> Result.map_error (fun (`Msg e) -> e) 927 959 in 928 - (* Create the bucket if it doesn't exist *) 929 960 Log.app (fun m -> m "Ensuring bucket '%s' exists..." config.storage.bucket); 930 961 let _ = 931 962 Bos.OS.Cmd.run
+22 -11
lib/homebrew.mli
··· 22 22 type head_dep = { dep_name : string; dep_type : [ `Build | `Recommended ] } 23 23 (** Head-build dependency specification. *) 24 24 25 - type binary = { 25 + (** Linux build mode. [Static] produces a fully static binary via Alpine + 26 + musl, runnable on any Linux distro. [Linuxbrew] dynamically links against 27 + Homebrew-on-Linux libraries, producing a bottle for brew-on-Linux users 28 + only. *) 29 + type linux_mode = Static | Linuxbrew 30 + 31 + type build = { linux : linux_mode } 32 + (** Build strategy. *) 33 + 34 + type package = { 26 35 name : string; 27 36 target : string; 28 37 description : string; ··· 30 39 head_deps : head_dep list; 31 40 conflicts_with : string list; 32 41 } 33 - (** Binary specification. [target] is the path to the dune executable target 34 - (e.g., "merlint/bin/main.exe"). [conflicts_with] lists formula names that 35 - provide the same binary (e.g., ["graphviz"] for a "prune" binary). *) 42 + (** Package specification. [target] is the path to the dune executable 43 + (e.g., "merlint/bin/main.exe"). [conflicts_with] lists formula names 44 + that provide the same binary. *) 36 45 37 46 type config = { 38 47 handle : string; ··· 40 49 license : string; 41 50 storage : storage; 42 51 tap : tap; 43 - binaries : binary list; 52 + build : build; 53 + packages : package list; 44 54 build_dir : string; 45 55 } 46 56 (** Top-level configuration. *) ··· 73 83 74 84 (** {1 Building} *) 75 85 76 - val build : config -> string list -> (string list, string) result 77 - (** [build config names] builds bottles for the given binary names (or all if 78 - empty). Returns the list of built bottle file paths. *) 86 + val build_packages : config -> string list -> (string list, string) result 87 + (** [build_packages config names] builds bottles for the given package names 88 + (or all if empty). Returns the list of built bottle file paths. *) 79 89 80 90 (** {1 Uploading} *) 81 91 82 92 val upload : config -> string list -> (unit, string) result 83 - (** [upload config files] uploads bottle files to object storage. *) 93 + (** [upload config files] uploads bottle files to object storage under 94 + [{package}/{platform}/] prefixes. *) 84 95 85 96 (** {1 Formula Generation} *) 86 97 87 - val generate_formula : config -> binary -> string 88 - (** [generate_formula config binary] generates a Homebrew Ruby formula. *) 98 + val generate_formula : config -> package -> string 99 + (** [generate_formula config package] generates a Homebrew Ruby formula. *) 89 100 90 101 val generate_readme : config -> string 91 102 (** [generate_readme config] generates a tap README.md. *)
+79 -85
test/test_homebrew.ml
··· 8 8 tap: 9 9 clone_url: https://example.com/tap.git 10 10 push_url: git@example.com:tap 11 - binaries: 11 + packages: 12 12 - name: myapp 13 13 target: ocaml-myapp/bin/main.exe 14 14 description: "A test application" ··· 39 39 "push_url" "git@example.com:tap" config.tap.push_url; 40 40 Alcotest.(check string) 41 41 "local_path" "../homebrew-monopam" 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 target" "ocaml-myapp/bin/main.exe" b0.target; 46 - Alcotest.(check string) "bin desc" "A test application" b0.description; 42 + Alcotest.(check bool) 43 + "build.linux defaults to Static" true 44 + (config.build.linux = Homebrew.Static); 45 + Alcotest.(check int) "package count" 2 (List.length config.packages); 46 + let p0 = List.nth config.packages 0 in 47 + Alcotest.(check string) "pkg name" "myapp" p0.name; 48 + Alcotest.(check string) "pkg target" "ocaml-myapp/bin/main.exe" p0.target; 49 + Alcotest.(check string) "pkg desc" "A test application" p0.description; 47 50 Alcotest.(check string) 48 - "bin homepage" "https://example.com/myapp" 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 51 + "pkg homepage" "https://example.com/myapp" p0.homepage; 52 + let p1 = List.nth config.packages 1 in 53 + Alcotest.(check string) "pkg1 name" "mytool" p1.name; 54 + Alcotest.(check int) "pkg1 head_deps" 1 (List.length p1.head_deps); 55 + let dep = List.nth p1.head_deps 0 in 53 56 Alcotest.(check string) "dep name" "afl-fuzz" dep.dep_name 54 57 55 58 let test_defaults () = ··· 63 66 tap: 64 67 clone_url: https://c 65 68 push_url: git@p 66 - binaries: [] 69 + packages: [] 67 70 |} 68 71 in 69 72 match Yamlt.decode_string Homebrew.config_jsont yaml with ··· 72 75 Alcotest.(check string) 73 76 "mono_url" "https://tangled.org/alice.example/mono.git" config.mono_url; 74 77 Alcotest.(check string) "license" "ISC" config.license; 75 - Alcotest.(check string) "build_dir" "_homebrew_build" config.build_dir 78 + Alcotest.(check string) "build_dir" "_homebrew_build" config.build_dir; 79 + Alcotest.(check bool) 80 + "build.linux defaults to Static" true 81 + (config.build.linux = Homebrew.Static) 82 + 83 + let test_build_mode_linuxbrew () = 84 + let yaml = 85 + {| 86 + handle: alice.example 87 + storage: 88 + bucket: b 89 + region: r 90 + profile: p 91 + tap: 92 + clone_url: https://c 93 + push_url: git@p 94 + build: 95 + linux: linuxbrew 96 + packages: [] 97 + |} 98 + in 99 + match Yamlt.decode_string Homebrew.config_jsont yaml with 100 + | Error e -> Alcotest.failf "parse error: %s" e 101 + | Ok config -> 102 + Alcotest.(check bool) 103 + "build.linux is Linuxbrew" true 104 + (config.build.linux = Homebrew.Linuxbrew) 76 105 77 106 let test_platform_roundtrip () = 78 107 let platforms = ··· 93 122 (String.length s > 0)) 94 123 platforms 95 124 125 + let test_config : Homebrew.config = 126 + { 127 + handle = "test.example"; 128 + mono_url = "https://example.com/mono.git"; 129 + license = "ISC"; 130 + storage = 131 + { 132 + bucket = "test-bottles"; 133 + region = "us-east"; 134 + profile = "test"; 135 + endpoint = "https://s3.us-east.scw.cloud"; 136 + rclone_remote = "scaleway"; 137 + }; 138 + tap = 139 + { 140 + clone_url = "https://example.com/tap.git"; 141 + push_url = "git@example.com:tap"; 142 + local_path = "../tap"; 143 + }; 144 + build = { linux = Static }; 145 + packages = []; 146 + build_dir = "_build"; 147 + } 148 + 96 149 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 = 150 + let pkg : Homebrew.package = 121 151 { 122 152 name = "myapp"; 123 153 target = "ocaml-myapp/bin/main.exe"; ··· 127 157 conflicts_with = []; 128 158 } 129 159 in 130 - let formula = Homebrew.generate_formula config bin in 160 + let formula = Homebrew.generate_formula test_config pkg in 131 161 Alcotest.(check bool) 132 162 "has class" true 133 163 (Astring.String.is_infix ~affix:"class Myapp < Formula" formula); ··· 142 172 "has license" true 143 173 (Astring.String.is_infix ~affix:"license \"ISC\"" formula); 144 174 Alcotest.(check bool) 145 - "has arm64" true 146 - (Astring.String.is_infix ~affix:"arm64_sonoma" formula); 175 + "has arm64_sonoma path" true 176 + (Astring.String.is_infix ~affix:"/myapp/arm64_sonoma/" formula); 147 177 Alcotest.(check bool) 148 - "has sonoma" true 149 - (Astring.String.is_infix ~affix:"sonoma.bottle" formula); 178 + "has sonoma path" true 179 + (Astring.String.is_infix ~affix:"/myapp/sonoma/" formula); 150 180 Alcotest.(check bool) 151 - "has linux" true 152 - (Astring.String.is_infix ~affix:"x86_64_linux" formula); 181 + "has linux path" true 182 + (Astring.String.is_infix ~affix:"/myapp/x86_64_linux/" formula); 153 183 Alcotest.(check bool) 154 184 "has docker dep" true 155 185 (Astring.String.is_infix ~affix:"depends_on \"docker\" => :recommended" ··· 166 196 (Astring.String.is_infix ~affix:"system bin/\"myapp\", \"--help\"" formula) 167 197 168 198 let test_generate_formula_kebab_case () = 169 - let config : Homebrew.config = 170 - { 171 - handle = "test.example"; 172 - mono_url = "https://example.com/mono.git"; 173 - license = "ISC"; 174 - storage = 175 - { 176 - bucket = "b"; 177 - region = "r"; 178 - profile = "p"; 179 - endpoint = "e"; 180 - rclone_remote = "s"; 181 - }; 182 - tap = { clone_url = "c"; push_url = "p"; local_path = "l" }; 183 - binaries = []; 184 - build_dir = "_build"; 185 - } 186 - in 187 - let bin : Homebrew.binary = 199 + let pkg : Homebrew.package = 188 200 { 189 201 name = "mdns-query"; 190 202 target = "ocaml-mdns/bin/mdns_query.exe"; ··· 194 206 conflicts_with = []; 195 207 } 196 208 in 197 - let formula = Homebrew.generate_formula config bin in 209 + let formula = Homebrew.generate_formula test_config pkg in 198 210 Alcotest.(check bool) 199 211 "kebab to CamelCase" true 200 212 (Astring.String.is_infix ~affix:"class MdnsQuery < Formula" formula) 201 213 202 214 let test_generate_formula_custom_exe () = 203 - let config : Homebrew.config = 204 - { 205 - handle = "test.example"; 206 - mono_url = "https://example.com/mono.git"; 207 - license = "ISC"; 208 - storage = 209 - { 210 - bucket = "b"; 211 - region = "r"; 212 - profile = "p"; 213 - endpoint = "e"; 214 - rclone_remote = "s"; 215 - }; 216 - tap = { clone_url = "c"; push_url = "p"; local_path = "l" }; 217 - binaries = []; 218 - build_dir = "_build"; 219 - } 220 - in 221 - let bin : Homebrew.binary = 215 + let pkg : Homebrew.package = 222 216 { 223 217 name = "agent"; 224 218 target = "ocaml-agent/bin/main.exe"; ··· 228 222 conflicts_with = []; 229 223 } 230 224 in 231 - let formula = Homebrew.generate_formula config bin in 225 + let formula = Homebrew.generate_formula test_config pkg in 232 226 Alcotest.(check bool) 233 227 "uses custom exe_name for dune build" true 234 228 (Astring.String.is_infix ··· 248 242 Alcotest.failf "sha256 error: %s" e 249 243 | Ok hash -> 250 244 Sys.remove tmp; 251 - (* sha256 of "hello world\n" *) 252 245 Alcotest.(check string) 253 246 "sha256" 254 247 "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" hash ··· 258 251 [ 259 252 Alcotest.test_case "config load" `Quick test_load_config; 260 253 Alcotest.test_case "config defaults" `Quick test_defaults; 254 + Alcotest.test_case "build mode linuxbrew" `Quick test_build_mode_linuxbrew; 261 255 Alcotest.test_case "platform roundtrip" `Quick test_platform_roundtrip; 262 256 Alcotest.test_case "formula generate" `Quick test_generate_formula; 263 257 Alcotest.test_case "formula kebab-case" `Quick