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.

bottler, homebrew: split ocaml-homebrew into generic lib + bottler CLI

- ocaml-homebrew becomes a generic library (Platform, Bottle, Formula, Tap)
free of YAML config, S3, Scaleway, or monorepo assumptions. Consumable by
opam, dune-pkg, or any tool that wants Homebrew formula primitives.

- New bottler package contains the CLI, YAML config loading, build
orchestration, rclone upload, release pipeline, and GitHub Actions
workflow generator. Depends on homebrew for formula/tap primitives.

- Config: rename binaries -> packages, add build.linux: static | linuxbrew,
reshape S3 layout from flat to {package}/{platform}/{file}.

- Workflow: branches on build.linux. Static uses ocaml/opam:alpine-3.20
with -cclib -static; linuxbrew uses ghcr.io/homebrew/ubuntu22.04 with
brew-installed libs. Adds opam cache step.

- Tests cover platform roundtrip, bottle parsing, formula emission, tap
name derivation, config loading, build mode dispatch, workflow shape.

+590 -1908
-24
bin/cmd_build.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let cmd = 5 - let run () config_file names = 6 - let config = load_config config_file in 7 - let paths = or_die (Homebrew.build_packages config names) in 8 - List.iter (fun p -> Fmt.pr "Built: %s@." p) paths 9 - in 10 - let info = 11 - Cmd.info "build" ~doc:"Build Homebrew bottles for the current platform." 12 - ~man: 13 - [ 14 - `S Manpage.s_description; 15 - `P 16 - "Builds the monorepo with dune, then packages each configured \ 17 - package as a Homebrew bottle tarball."; 18 - ] 19 - in 20 - Cmd.v info 21 - Term.( 22 - const run 23 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 24 - $ config_file $ package_names)
-40
bin/cmd_config.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let cmd = 5 - let run () config_file = 6 - let config = load_config config_file in 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 16 - config.storage.bucket config.storage.region; 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@." 21 - (Homebrew.platform_to_string (Homebrew.detect_platform ())); 22 - Fmt.pr "Packages:@."; 23 - List.iter 24 - (fun (p : Homebrew.package) -> 25 - Fmt.pr " %-12s %s (%s)@." p.name p.description p.target) 26 - config.packages 27 - in 28 - let info = 29 - Cmd.info "config" ~doc:"Show parsed configuration." 30 - ~man: 31 - [ 32 - `S Manpage.s_description; 33 - `P "Loads and displays the parsed YAML configuration."; 34 - ] 35 - in 36 - Cmd.v info 37 - Term.( 38 - const run 39 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 40 - $ config_file)
-23
bin/cmd_doctor.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let cmd = 5 - let run () config_file = 6 - let config = load_config config_file in 7 - or_die (Homebrew.doctor config) 8 - in 9 - let info = 10 - Cmd.info "doctor" ~doc:"Check tools and credentials." 11 - ~man: 12 - [ 13 - `S Manpage.s_description; 14 - `P 15 - "Verifies that all required tools (rclone, scw, git, opam, dune) \ 16 - are installed and credentials are configured."; 17 - ] 18 - in 19 - Cmd.v info 20 - Term.( 21 - const run 22 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 23 - $ config_file)
-36
bin/cmd_formula.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let cmd = 5 - let run () config_file names = 6 - let config = load_config config_file in 7 - let packages = 8 - match names with 9 - | [] -> config.packages 10 - | names -> 11 - List.filter 12 - (fun (p : Homebrew.package) -> List.mem p.name names) 13 - config.packages 14 - in 15 - List.iter 16 - (fun pkg -> 17 - let formula = Homebrew.generate_formula config pkg in 18 - Fmt.pr "%s" formula) 19 - packages 20 - in 21 - let info = 22 - Cmd.info "formula" ~doc:"Generate Homebrew Ruby formulas." 23 - ~man: 24 - [ 25 - `S Manpage.s_description; 26 - `P 27 - "Generates Ruby formula files for the configured packages. \ 28 - Formulas include platform-specific bottle URLs and source build \ 29 - instructions."; 30 - ] 31 - in 32 - Cmd.v info 33 - Term.( 34 - const run 35 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 36 - $ config_file $ package_names)
-28
bin/cmd_login.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let cmd = 5 - let run () config_file = 6 - let config = load_config config_file in 7 - or_die (Homebrew.login config) 8 - in 9 - let info = 10 - Cmd.info "login" ~doc:"Set up Scaleway and rclone credentials." 11 - ~man: 12 - [ 13 - `S Manpage.s_description; 14 - `P 15 - "Interactively configures Scaleway CLI credentials and generates \ 16 - the rclone configuration needed for bottle uploads."; 17 - `S "SETUP STEPS"; 18 - `P "1. Creates a Scaleway CLI profile with your API keys"; 19 - `P "2. Generates rclone configuration from the Scaleway profile"; 20 - `P "3. Creates the S3 bucket if it doesn't exist"; 21 - `P "Get your API keys from: https://console.scaleway.com/iam/api-keys"; 22 - ] 23 - in 24 - Cmd.v info 25 - Term.( 26 - const run 27 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 28 - $ config_file)
-45
bin/cmd_release.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let cmd = 5 - let run () config_file dry_run names = 6 - let config = load_config config_file in 7 - let packages = 8 - match names with 9 - | [] -> config.Homebrew.packages 10 - | names -> 11 - List.filter 12 - (fun (p : Homebrew.package) -> List.mem p.name names) 13 - config.packages 14 - in 15 - if dry_run then begin 16 - Fmt.pr "Would build the following targets:@."; 17 - List.iter 18 - (fun (pkg : Homebrew.package) -> 19 - Fmt.pr " dune build --profile=release %s@." pkg.target) 20 - packages; 21 - Fmt.pr "@.Packages: %s@." 22 - (String.concat ", " (List.map (fun p -> p.Homebrew.name) packages)) 23 - end 24 - else or_die (Homebrew.release config names) 25 - in 26 - let dry_run = 27 - Arg.(value & flag & info [ "dry-run"; "n" ] ~doc:"Show what would be built") 28 - in 29 - let info = 30 - Cmd.info "release" ~doc:"Full release: build, upload, and update tap." 31 - ~man: 32 - [ 33 - `S Manpage.s_description; 34 - `P 35 - "Performs the full release workflow: builds bottles for the \ 36 - current platform, uploads them to object storage, generates or \ 37 - updates formula files with SHA256 checksums, and pushes the tap \ 38 - repository."; 39 - ] 40 - in 41 - Cmd.v info 42 - Term.( 43 - const run 44 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 45 - $ config_file $ dry_run $ package_names)
-45
bin/cmd_update.ml
··· 1 - open Cmdliner 2 - 3 - let run config_file names = 4 - let config = Common.load_config config_file in 5 - let packages = 6 - match names with 7 - | [] -> config.Homebrew.packages 8 - | names -> 9 - List.filter 10 - (fun (p : Homebrew.package) -> List.mem p.name names) 11 - config.packages 12 - in 13 - if packages = [] then ( 14 - Fmt.epr "No matching packages found.@."; 15 - exit 1); 16 - let tap_name = Homebrew.tap_name config in 17 - (match Bos.OS.Cmd.run Bos.Cmd.(v "brew" % "update") with 18 - | Ok () -> () 19 - | Error _ -> Fmt.epr "Warning: brew update failed@."); 20 - let installed = 21 - List.filter 22 - (fun (p : Homebrew.package) -> 23 - let formula = Fmt.str "%s/%s" tap_name p.name in 24 - match Bos.OS.Cmd.run_status Bos.Cmd.(v "brew" % "list" % formula) with 25 - | Ok (`Exited 0) -> true 26 - | _ -> false) 27 - packages 28 - in 29 - if installed = [] then ( 30 - Fmt.pr "No installed packages to upgrade.@."; 31 - exit 0); 32 - List.iter 33 - (fun (p : Homebrew.package) -> 34 - let formula = Fmt.str "%s/%s" tap_name p.name in 35 - Fmt.pr "Upgrading %s...@." p.name; 36 - match Bos.OS.Cmd.run Bos.Cmd.(v "brew" % "upgrade" % formula) with 37 - | Ok () -> () 38 - | Error _ -> Fmt.epr " Failed to upgrade %s@." p.name) 39 - installed; 40 - Fmt.pr "Done. %d packages upgraded.@." (List.length installed) 41 - 42 - let cmd = 43 - let doc = "Upgrade installed bottled packages via brew." in 44 - let info = Cmd.info "update" ~doc in 45 - Cmd.v info Term.(const run $ Common.config_file $ Common.package_names)
-29
bin/cmd_upload.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let files_arg = 5 - let doc = "Bottle files to upload (all in build dir if omitted)." in 6 - Arg.(value & pos_all string [] & info [] ~docv:"FILE" ~doc) 7 - 8 - let cmd = 9 - let run () config_file files = 10 - let config = load_config config_file in 11 - or_die (Homebrew.upload config files); 12 - Fmt.pr "Upload complete.@." 13 - in 14 - let info = 15 - Cmd.info "upload" ~doc:"Upload bottles to S3-compatible object storage." 16 - ~man: 17 - [ 18 - `S Manpage.s_description; 19 - `P 20 - "Uploads bottle tarballs to the configured storage bucket using \ 21 - rclone. Each bottle is uploaded both with its dated name and as a \ 22 - 'latest' rolling release."; 23 - ] 24 - in 25 - Cmd.v info 26 - Term.( 27 - const run 28 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 29 - $ config_file $ files_arg)
-278
bin/cmd_workflow.ml
··· 1 - open Cmdliner 2 - open Common 3 - 4 - let bottle_steps = 5 - {| 6 - - name: Build bottles 7 - working-directory: mono 8 - run: | 9 - set -eo pipefail 10 - if [ "${{ inputs.package }}" = "all" ]; then 11 - opam exec -- dune exec -- bottler build 12 - else 13 - opam exec -- dune exec -- bottler build "${{ inputs.package }}" 14 - fi 15 - 16 - - name: Upload bottles to storage 17 - working-directory: mono 18 - run: | 19 - set -eo pipefail 20 - if [ "${{ inputs.package }}" = "all" ]; then 21 - opam exec -- dune exec -- bottler upload 22 - else 23 - opam exec -- dune exec -- bottler upload "${{ inputs.package }}" 24 - fi 25 - env: 26 - AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }} 27 - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }} 28 - 29 - - name: Save checksums 30 - uses: actions/upload-artifact@v4 31 - with: 32 - name: checksums-${{ matrix.platform }} 33 - path: mono/*.sha256|} 34 - 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 92 - Fmt.str 93 - {| build: 94 - strategy: 95 - matrix: 96 - include: 97 - - os: macos-14 98 - platform: arm64_sonoma 99 - container: '' 100 - %s 101 - runs-on: ${{ matrix.os }} 102 - container: ${{ matrix.container }} 103 - steps: 104 - - name: Checkout monorepo 105 - run: git clone --depth 1 %s mono 106 - 107 - - name: Install build deps (macOS) 108 - if: runner.os == 'macOS' 109 - run: brew install opam pkg-config gmp libffi 110 - 111 - %s 112 - %s 113 - 114 - - name: Setup OCaml 115 - run: | 116 - set -eo pipefail 117 - opam init --disable-sandboxing --no-setup -y 118 - opam switch create 5.3.0 --yes || opam switch 5.3.0 119 - 120 - - name: Install dependencies 121 - working-directory: mono 122 - run: opam install . --deps-only --with-test=false --yes 123 - 124 - - name: Build bottler 125 - working-directory: mono 126 - run: opam exec -- dune build --release ocaml-homebrew/bin/main.exe%s|} 127 - linux_matrix config.Homebrew.mono_url linux_setup opam_cache_step 128 - bottle_steps 129 - 130 - let formula_update_steps package_names = 131 - Fmt.str 132 - {| 133 - - name: Update formulas with checksums 134 - run: | 135 - set -eo pipefail 136 - mkdir -p Formula 137 - cd mono 138 - if [ "${{ inputs.package }}" = "all" ]; then 139 - for name in %s; do 140 - opam exec -- dune exec -- bottler formula "$name" > ../Formula/"$name".rb 141 - done 142 - else 143 - opam exec -- dune exec -- bottler formula "${{ inputs.package }}" > "../Formula/${{ inputs.package }}.rb" 144 - fi 145 - 146 - - name: Commit and push 147 - run: | 148 - set -eo pipefail 149 - git config user.name "GitHub Actions" 150 - git config user.email "actions@github.com" 151 - git add Formula/ 152 - git diff --staged --quiet || git commit -m "Update bottles for ${{ inputs.package }}" 153 - git push origin HEAD|} 154 - package_names 155 - 156 - let update_tap_job mono_url package_names = 157 - Fmt.str 158 - {| update-tap: 159 - needs: build 160 - runs-on: ubuntu-latest 161 - steps: 162 - - uses: actions/checkout@v4 163 - 164 - - name: Download checksums 165 - uses: actions/download-artifact@v4 166 - with: 167 - path: checksums 168 - 169 - - name: Checkout monorepo 170 - run: git clone --depth 1 %s mono 171 - 172 - - name: Install opam 173 - run: | 174 - set -eo pipefail 175 - sudo apt-get update 176 - sudo apt-get install -y opam libgmp-dev libffi-dev pkg-config 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 - 185 - - name: Setup OCaml 186 - run: | 187 - set -eo pipefail 188 - opam init --disable-sandboxing --no-setup -y 189 - opam switch create 5.3.0 --yes || opam switch 5.3.0 190 - 191 - - name: Install dependencies 192 - working-directory: mono 193 - run: opam install . --deps-only --with-test=false --yes 194 - 195 - - name: Build bottler 196 - working-directory: mono 197 - run: opam exec -- dune build --release ocaml-homebrew/bin/main.exe%s|} 198 - mono_url 199 - (formula_update_steps package_names) 200 - 201 - let generate_workflow (config : Homebrew.config) = 202 - let packages_list = 203 - config.packages |> List.map (fun p -> p.Homebrew.name) |> String.concat ", " 204 - in 205 - let package_options = 206 - config.packages 207 - |> List.map (fun p -> Fmt.str " - %s" p.Homebrew.name) 208 - |> String.concat "\n" 209 - in 210 - let package_names = 211 - config.packages |> List.map (fun p -> p.Homebrew.name) |> String.concat " " 212 - in 213 - Fmt.str 214 - {|name: Release Bottles 215 - 216 - on: 217 - workflow_dispatch: 218 - inputs: 219 - package: 220 - description: 'Package to release (%s)' 221 - required: true 222 - default: 'all' 223 - type: choice 224 - options: 225 - - all 226 - %s 227 - 228 - jobs: 229 - %s 230 - 231 - %s 232 - |} 233 - packages_list package_options (build_job config) 234 - (update_tap_job config.mono_url package_names) 235 - 236 - let cmd = 237 - let run () config_file output = 238 - let config = load_config config_file in 239 - let workflow = generate_workflow config in 240 - match output with 241 - | None -> Fmt.pr "%s" workflow 242 - | Some path -> 243 - let oc = open_out path in 244 - output_string oc workflow; 245 - close_out oc; 246 - Fmt.pr "Wrote workflow to %s@." path 247 - in 248 - let output = 249 - Arg.( 250 - value 251 - & opt (some string) None 252 - & info [ "o"; "output" ] ~docv:"FILE" 253 - ~doc:"Write workflow to FILE instead of stdout") 254 - in 255 - let info = 256 - Cmd.info "workflow" ~doc:"Generate GitHub Actions release workflow." 257 - ~man: 258 - [ 259 - `S Manpage.s_description; 260 - `P 261 - "Generates a GitHub Actions workflow file for building and \ 262 - releasing Homebrew bottles across multiple platforms (macOS \ 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."; 267 - `S Manpage.s_examples; 268 - `P "Generate workflow to stdout:"; 269 - `Pre "bottler workflow"; 270 - `P "Write workflow to file:"; 271 - `Pre "bottler workflow -o .github/workflows/release.yml"; 272 - ] 273 - in 274 - Cmd.v info 275 - Term.( 276 - const run 277 - $ (const (fun () () -> ()) $ Vlog.setup "bottler" $ Memtrace.term) 278 - $ config_file $ output)
-27
bin/common.ml
··· 1 - open Cmdliner 2 - 3 - let src = Logs.Src.create "bottler" ~doc:"Homebrew bottle builder" 4 - 5 - module Log = (val Logs.src_log src) 6 - 7 - let config_file = 8 - let doc = "Path to the YAML configuration file." in 9 - Arg.( 10 - value & opt string "homebrew.yml" & info [ "c"; "config" ] ~docv:"FILE" ~doc) 11 - 12 - let package_names = 13 - let doc = "Package names to operate on (all if omitted)." in 14 - Arg.(value & pos_all string [] & info [] ~docv:"NAME" ~doc) 15 - 16 - let load_config file = 17 - match Homebrew.load_config file with 18 - | Ok c -> c 19 - | Error e -> 20 - Fmt.epr "Error: %s@." e; 21 - exit 1 22 - 23 - let or_die = function 24 - | Ok v -> v 25 - | Error e -> 26 - Logs.err (fun m -> m "%s" e); 27 - exit 1
-4
bin/dune
··· 1 - (executable 2 - (name main) 3 - (public_name bottler) 4 - (libraries homebrew cmdliner vlog fmt logs monopam-info memtrace))
-39
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 cmd = 9 - let info = 10 - Cmd.info "bottler" ~version:Monopam_info.version 11 - ~doc:"Homebrew bottle builder and tap manager for OCaml monorepos." 12 - ~man: 13 - [ 14 - `S Manpage.s_description; 15 - `P 16 - "Build, upload, and release Homebrew bottles from dune monorepos. \ 17 - Generates Ruby formulas with platform-specific SHA256 checksums, \ 18 - uploads bottles to S3-compatible storage, and manages tap \ 19 - repositories."; 20 - `S "CONFIGURATION"; 21 - `P 22 - "Configuration is read from a YAML file (default: homebrew.yml). \ 23 - See the README for the full configuration format."; 24 - ] 25 - in 26 - Cmd.group info 27 - [ 28 - Cmd_build.cmd; 29 - Cmd_upload.cmd; 30 - Cmd_formula.cmd; 31 - Cmd_release.cmd; 32 - Cmd_workflow.cmd; 33 - Cmd_config.cmd; 34 - Cmd_login.cmd; 35 - Cmd_doctor.cmd; 36 - Cmd_update.cmd; 37 - ] 38 - 39 - let () = exit (Cmd.eval cmd)
+8 -9
dune-project
··· 13 13 14 14 (package 15 15 (name homebrew) 16 - (synopsis "Homebrew bottle builder and tap manager for OCaml monorepos") 17 - (tags (org:blacksun system cli)) 16 + (synopsis "Generic Homebrew primitives: formulas, taps, bottle conventions") 17 + (tags (org:blacksun packaging homebrew)) 18 18 (description 19 - "Build, upload, and release Homebrew bottles from dune monorepos. 20 - Generates Ruby formulas with platform-specific SHA256 checksums, 21 - uploads bottles to S3-compatible storage, and manages tap repositories.") 19 + "Pure-OCaml building blocks for working with Homebrew: Ruby formula 20 + generation, tap repository operations, bottle filename conventions, 21 + and platform detection. No YAML config, no S3, no monorepo assumptions 22 + -- just the Homebrew-flavoured primitives. Consumable by tools like 23 + bottler, or by opam / dune-pkg if they ever want to emit Homebrew 24 + formulas.") 22 25 (depends 23 26 (ocaml (>= 5.1)) 24 - yamlt 25 - jsont 26 27 (bos (>= 0.2)) 27 - (cmdliner (>= 1.3.0)) 28 28 (fmt (>= 0.9)) 29 29 (logs (>= 0.8)) 30 - (vlog (>= 0.1)) 31 30 (fpath (>= 0.7)) 32 31 (digestif (>= 1.0)) 33 32 (astring (>= 0.8))
+28 -33
fuzz/fuzz_homebrew.ml
··· 5 5 6 6 open Alcobar 7 7 8 - let test_yaml_parse input = 9 - let _ = Yamlt.decode_string Homebrew.config_jsont input in 10 - () 11 - 12 - let gen_package = 8 + let gen_formula = 13 9 map [ bytes; bytes ] (fun name description -> 14 10 let name = 15 11 if String.length name = 0 then "x" ··· 19 15 String.sub description 0 (min 100 (String.length description)) 20 16 in 21 17 ({ 22 - Homebrew.name; 23 - target = Fmt.str "%s/bin/main.exe" name; 18 + name; 24 19 description; 25 20 homepage = ""; 26 - head_deps = []; 21 + license = "ISC"; 22 + version = "latest"; 27 23 conflicts_with = []; 24 + bottles = []; 25 + head = None; 26 + install = ""; 27 + test = ""; 28 28 } 29 - : Homebrew.package)) 29 + : Homebrew.Formula.t)) 30 + 31 + let test_to_ruby_no_crash (t : Homebrew.Formula.t) = 32 + let _ = Homebrew.Formula.to_ruby t in 33 + () 34 + 35 + let test_bottle_parse_no_crash input = 36 + let _ = Homebrew.Bottle.parse_filename input in 37 + () 38 + 39 + let test_class_name_no_crash s = 40 + let _ = Homebrew.Formula.class_name s in 41 + () 30 42 31 - let test_formula_gen (pkg : Homebrew.package) = 32 - let config : Homebrew.config = 33 - { 34 - handle = "test"; 35 - mono_url = "https://example.com/mono.git"; 36 - license = "ISC"; 37 - storage = 38 - { 39 - bucket = "b"; 40 - region = "r"; 41 - profile = "p"; 42 - endpoint = "e"; 43 - rclone_remote = "s"; 44 - }; 45 - tap = { clone_url = "c"; push_url = "p"; local_path = "l" }; 46 - build = { linux = Homebrew.Static }; 47 - packages = []; 48 - build_dir = "_build"; 49 - } 50 - in 51 - let formula = Homebrew.generate_formula config pkg in 52 - check (String.length formula > 0) 43 + let test_tap_name_no_crash s = 44 + let _ = Homebrew.Tap.name_of_url s in 45 + () 53 46 54 47 let suite = 55 48 ( "homebrew", 56 49 [ 57 - test_case "yaml parse no crash" [ bytes ] test_yaml_parse; 58 - test_case "formula gen no crash" [ gen_package ] test_formula_gen; 50 + test_case "formula to_ruby no crash" [ gen_formula ] test_to_ruby_no_crash; 51 + test_case "bottle parse no crash" [ bytes ] test_bottle_parse_no_crash; 52 + test_case "class_name no crash" [ bytes ] test_class_name_no_crash; 53 + test_case "tap name_of_url no crash" [ bytes ] test_tap_name_no_crash; 59 54 ] )
+8 -9
homebrew.opam
··· 1 1 # This file is generated by dune, edit dune-project instead 2 2 opam-version: "2.0" 3 - synopsis: "Homebrew bottle builder and tap manager for OCaml monorepos" 3 + synopsis: "Generic Homebrew primitives: formulas, taps, bottle conventions" 4 4 description: """ 5 - Build, upload, and release Homebrew bottles from dune monorepos. 6 - Generates Ruby formulas with platform-specific SHA256 checksums, 7 - uploads bottles to S3-compatible storage, and manages tap repositories.""" 5 + Pure-OCaml building blocks for working with Homebrew: Ruby formula 6 + generation, tap repository operations, bottle filename conventions, 7 + and platform detection. No YAML config, no S3, no monorepo assumptions 8 + -- just the Homebrew-flavoured primitives. Consumable by tools like 9 + bottler, or by opam / dune-pkg if they ever want to emit Homebrew 10 + formulas.""" 8 11 maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 9 12 authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 10 13 license: "ISC" 11 - tags: ["org:blacksun" "system" "cli"] 14 + tags: ["org:blacksun" "packaging" "homebrew"] 12 15 homepage: "https://tangled.org/gazagnaire.org/ocaml-homebrew" 13 16 bug-reports: "https://tangled.org/gazagnaire.org/ocaml-homebrew/issues" 14 17 depends: [ 15 18 "dune" {>= "3.21"} 16 19 "ocaml" {>= "5.1"} 17 - "yamlt" 18 - "jsont" 19 20 "bos" {>= "0.2"} 20 - "cmdliner" {>= "1.3.0"} 21 21 "fmt" {>= "0.9"} 22 22 "logs" {>= "0.8"} 23 - "vlog" {>= "0.1"} 24 23 "fpath" {>= "0.7"} 25 24 "digestif" {>= "1.0"} 26 25 "astring" {>= "0.8"}
+1 -1
lib/dune
··· 1 1 (library 2 2 (name homebrew) 3 3 (public_name homebrew) 4 - (libraries yamlt jsont bos fmt fpath logs digestif astring tty)) 4 + (libraries bos fmt fpath logs digestif astring))
+328 -919
lib/homebrew.ml
··· 5 5 6 6 module Log = (val Logs.src_log (Logs.Src.create "homebrew")) 7 7 8 - let err_parse_config e = Error (Fmt.str "config parse error: %s" e) 8 + module Platform = struct 9 + type t = 10 + | Arm64_sonoma 11 + | Sonoma 12 + | X86_64_linux 13 + | Arm64_linux 14 + | Unknown of string 9 15 10 - let err_not_found name = 11 - Error (Fmt.str "%s not found. Install with: brew install %s" name name) 16 + let to_string = function 17 + | Arm64_sonoma -> "arm64_sonoma" 18 + | Sonoma -> "sonoma" 19 + | X86_64_linux -> "x86_64_linux" 20 + | Arm64_linux -> "arm64_linux" 21 + | Unknown s -> s 12 22 13 - (* {1 Configuration} *) 23 + let of_string = function 24 + | "arm64_sonoma" -> Arm64_sonoma 25 + | "sonoma" -> Sonoma 26 + | "x86_64_linux" -> X86_64_linux 27 + | "arm64_linux" -> Arm64_linux 28 + | s -> Unknown s 14 29 15 - type storage = { 16 - bucket : string; 17 - region : string; 18 - profile : string; 19 - endpoint : string; 20 - rclone_remote : string; 21 - } 30 + let detect () = 31 + let uname flag = 32 + match 33 + Bos.OS.Cmd.run_out Bos.Cmd.(v "uname" % flag) |> Bos.OS.Cmd.out_string 34 + with 35 + | Ok (s, _) -> String.trim s 36 + | Error _ -> "unknown" 37 + in 38 + let os = String.lowercase_ascii (uname "-s") in 39 + let arch = uname "-m" in 40 + match (os, arch) with 41 + | "darwin", "arm64" -> Arm64_sonoma 42 + | "darwin", "x86_64" -> Sonoma 43 + | "linux", "x86_64" -> X86_64_linux 44 + | "linux", "aarch64" -> Arm64_linux 45 + | _ -> Unknown (Fmt.str "%s_%s" os arch) 46 + end 22 47 23 - type tap = { clone_url : string; push_url : string; local_path : string } 24 - type head_dep = { dep_name : string; dep_type : [ `Build | `Recommended ] } 25 - type linux_mode = Static | Linuxbrew 26 - type build = { linux : linux_mode } 48 + module Bottle = struct 49 + let filename ~package ~version ~(platform : Platform.t) = 50 + Fmt.str "%s-%s.%s.bottle.tar.gz" package version 51 + (Platform.to_string platform) 27 52 28 - type package = { 29 - name : string; 30 - target : string; 31 - description : string; 32 - homepage : string; 33 - head_deps : head_dep list; 34 - conflicts_with : string list; 35 - } 53 + let parse_filename name = 54 + let base = Filename.basename name in 55 + let rec find_version_sep i = 56 + match Astring.String.find_sub ~start:i ~sub:"-" base with 57 + | None -> None 58 + | Some pos -> 59 + if 60 + pos + 1 < String.length base 61 + && base.[pos + 1] >= '0' 62 + && base.[pos + 1] <= '9' 63 + then 64 + let pkg = String.sub base 0 pos in 65 + let rest = 66 + String.sub base (pos + 1) (String.length base - pos - 1) 67 + in 68 + Some (pkg, rest) 69 + else find_version_sep (pos + 1) 70 + in 71 + match find_version_sep 0 with 72 + | None -> None 73 + | Some (pkg, rest) -> ( 74 + match Astring.String.cut ~rev:true ~sep:".bottle.tar.gz" rest with 75 + | None -> None 76 + | Some (version_platform, _) -> ( 77 + match Astring.String.cut ~rev:true ~sep:"." version_platform with 78 + | None -> None 79 + | Some (version, platform_str) -> 80 + Some (pkg, version, Platform.of_string platform_str))) 36 81 37 - type config = { 38 - handle : string; 39 - mono_url : string; 40 - license : string; 41 - storage : storage; 42 - tap : tap; 43 - build : build; 44 - packages : package list; 45 - build_dir : string; 46 - } 82 + let sha256_file path = 83 + match Bos.OS.File.read (Fpath.v path) with 84 + | Error (`Msg e) -> Error e 85 + | Ok content -> 86 + let hash = Digestif.SHA256.digest_string content in 87 + Ok (Digestif.SHA256.to_hex hash) 88 + end 47 89 48 - type platform = 49 - | Arm64_sonoma 50 - | Sonoma 51 - | X86_64_linux 52 - | Arm64_linux 53 - | Unknown of string 90 + module Formula = struct 91 + type head_dep = { name : string; dep_type : [ `Build | `Recommended ] } 54 92 55 - (* {2 Jsont codecs} *) 93 + type bottle = { 94 + platform : Platform.t; 95 + url : string; 96 + sha : [ `Digest of string | `No_check ]; 97 + } 56 98 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 99 + type head = { 100 + url : string; 101 + branch : string; 102 + deps : head_dep list; 103 + install : string; 104 + } 66 105 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 ~enc:(fun d -> 72 - d.dep_type) 73 - |> Jsont.Object.finish 106 + type t = { 107 + name : string; 108 + description : string; 109 + homepage : string; 110 + license : string; 111 + version : string; 112 + conflicts_with : string list; 113 + bottles : bottle list; 114 + head : head option; 115 + install : string; 116 + test : string; 117 + } 74 118 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 119 + let capitalize_first s = 120 + if String.length s = 0 then s 121 + else 122 + let first = Char.uppercase_ascii s.[0] in 123 + String.make 1 first ^ String.sub s 1 (String.length s - 1) 83 124 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 125 + let class_name name = 126 + Astring.String.cuts ~sep:"-" name 127 + |> List.map capitalize_first |> String.concat "" 89 128 90 - let package_jsont : package Jsont.t = 91 - Jsont.Object.map ~kind:"package" 92 - (fun name target description homepage head_deps conflicts_with -> 93 - { name; target; description; homepage; head_deps; conflicts_with }) 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) 99 - |> Jsont.Object.mem "head_deps" (Jsont.list head_dep_jsont) ~dec_absent:[] 100 - ~enc:(fun p -> p.head_deps) 101 - |> Jsont.Object.mem "conflicts_with" (Jsont.list Jsont.string) ~dec_absent:[] 102 - ~enc:(fun p -> p.conflicts_with) 103 - |> Jsont.Object.finish 129 + let sha_line buf = function 130 + | `No_check -> Buffer.add_string buf " sha256 :no_check\n" 131 + | `Digest d -> Buffer.add_string buf (Fmt.str " sha256 \"%s\"\n" d) 104 132 105 - let storage_jsont : storage Jsont.t = 106 - Jsont.Object.map ~kind:"storage" 107 - (fun bucket region profile endpoint rclone_remote -> 108 - let endpoint = 109 - match endpoint with 110 - | Some e -> e 111 - | None -> Fmt.str "https://s3.%s.scw.cloud" region 112 - in 113 - let rclone_remote = 114 - match rclone_remote with Some r -> r | None -> "scaleway" 115 - in 116 - { bucket; region; profile; endpoint; rclone_remote }) 117 - |> Jsont.Object.mem "bucket" Jsont.string ~enc:(fun s -> s.bucket) 118 - |> Jsont.Object.mem "region" Jsont.string ~enc:(fun s -> s.region) 119 - |> Jsont.Object.mem "profile" Jsont.string ~enc:(fun s -> s.profile) 120 - |> Jsont.Object.opt_mem "endpoint" Jsont.string ~enc:(fun s -> 121 - Some s.endpoint) 122 - |> Jsont.Object.opt_mem "rclone_remote" Jsont.string ~enc:(fun s -> 123 - Some s.rclone_remote) 124 - |> Jsont.Object.finish 133 + let find_bottle bottles platform = 134 + List.find_opt (fun (b : bottle) -> b.platform = platform) bottles 125 135 126 - let tap_jsont : tap Jsont.t = 127 - Jsont.Object.map ~kind:"tap" (fun clone_url push_url local_path -> 128 - let local_path = 129 - match local_path with Some p -> p | None -> "../homebrew-monopam" 130 - in 131 - let local_path = 132 - match Fpath.segs (Fpath.v local_path) with 133 - | "~" :: rest -> 134 - let home = Fpath.v (Sys.getenv "HOME") in 135 - Fpath.to_string 136 - (List.fold_left (fun p s -> Fpath.(p / s)) home rest) 137 - | _ -> local_path 138 - in 139 - { clone_url; push_url; local_path }) 140 - |> Jsont.Object.mem "clone_url" Jsont.string ~enc:(fun t -> t.clone_url) 141 - |> Jsont.Object.mem "push_url" Jsont.string ~enc:(fun t -> t.push_url) 142 - |> Jsont.Object.opt_mem "local_path" Jsont.string ~enc:(fun t -> 143 - Some t.local_path) 144 - |> Jsont.Object.finish 136 + let pp_bottle ~indent buf (b : bottle) = 137 + let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 138 + pr "%surl \"%s\"\n" indent b.url; 139 + match b.sha with 140 + | `No_check -> pr "%ssha256 :no_check\n" indent 141 + | `Digest d -> pr "%ssha256 \"%s\"\n" indent d 145 142 146 - let config_jsont : config Jsont.t = 147 - Jsont.Object.map ~kind:"config" 148 - (fun handle mono_url license storage tap build packages build_dir -> 149 - let mono_url = 150 - match mono_url with 151 - | Some u -> u 152 - | None -> Fmt.str "https://tangled.org/%s/mono.git" handle 153 - in 154 - let license = match license with Some l -> l | None -> "ISC" in 155 - let build_dir = 156 - match build_dir with Some d -> d | None -> "_homebrew_build" 157 - in 158 - { handle; mono_url; license; storage; tap; build; packages; build_dir }) 159 - |> Jsont.Object.mem "handle" Jsont.string ~enc:(fun c -> c.handle) 160 - |> Jsont.Object.opt_mem "mono_url" Jsont.string ~enc:(fun c -> 161 - Some c.mono_url) 162 - |> Jsont.Object.opt_mem "license" Jsont.string ~enc:(fun c -> Some c.license) 163 - |> Jsont.Object.mem "storage" storage_jsont ~enc:(fun c -> c.storage) 164 - |> Jsont.Object.mem "tap" tap_jsont ~enc:(fun c -> c.tap) 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) 169 - |> Jsont.Object.opt_mem "build_dir" Jsont.string ~enc:(fun c -> 170 - Some c.build_dir) 171 - |> Jsont.Object.finish 143 + let render_bottles buf bottles = 144 + let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 145 + let arm = find_bottle bottles Arm64_sonoma in 146 + let intel = find_bottle bottles Sonoma in 147 + let lin = find_bottle bottles X86_64_linux in 148 + let has_mac = arm <> None || intel <> None in 149 + if has_mac then begin 150 + pr " on_macos do\n"; 151 + (match arm with 152 + | None -> () 153 + | Some b -> 154 + pr " on_arm do\n"; 155 + pp_bottle ~indent:" " buf b; 156 + pr " end\n"); 157 + (match intel with 158 + | None -> () 159 + | Some b -> 160 + pr " on_intel do\n"; 161 + pp_bottle ~indent:" " buf b; 162 + pr " end\n"); 163 + pr " end\n" 164 + end; 165 + match lin with 166 + | None -> () 167 + | Some b -> 168 + if has_mac then pr "\n"; 169 + pr " on_linux do\n"; 170 + pp_bottle ~indent:" " buf b; 171 + pr " end\n"; 172 + ignore sha_line 172 173 173 - let load_config path = 174 - match Bos.OS.File.read (Fpath.v path) with 175 - | Error (`Msg e) -> Error e 176 - | Ok content -> ( 177 - match Yamlt.decode_string config_jsont content with 178 - | Ok config -> 179 - Log.info (fun m -> 180 - m "Loaded config: %d packages" (List.length config.packages)); 181 - Ok config 182 - | Error e -> err_parse_config e) 174 + let render_head buf = function 175 + | None -> () 176 + | Some h -> 177 + let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 178 + pr " head \"%s\", branch: \"%s\"\n" h.url h.branch; 179 + pr "\n"; 180 + pr " head do\n"; 181 + List.iter 182 + (fun d -> 183 + let typ = 184 + match d.dep_type with 185 + | `Build -> ":build" 186 + | `Recommended -> ":recommended" 187 + in 188 + pr " depends_on \"%s\" => %s\n" d.name typ) 189 + h.deps; 190 + pr " end\n" 183 191 184 - (* {1 Platform Detection} *) 192 + let to_ruby t = 193 + let buf = Buffer.create 1024 in 194 + let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 195 + pr "class %s < Formula\n" (class_name t.name); 196 + pr " desc \"%s\"\n" t.description; 197 + pr " homepage \"%s\"\n" t.homepage; 198 + pr " license \"%s\"\n" t.license; 199 + pr " version \"%s\"\n" t.version; 200 + List.iter 201 + (fun f -> 202 + pr " conflicts_with \"%s\", because: \"both install a `%s` binary\"\n" 203 + f t.name) 204 + t.conflicts_with; 205 + pr "\n"; 206 + render_bottles buf t.bottles; 207 + pr "\n"; 208 + render_head buf t.head; 209 + pr "\n"; 210 + pr " def install\n"; 211 + if t.install <> "" then 212 + List.iter 213 + (fun line -> if line = "" then pr "\n" else pr " %s\n" line) 214 + (Astring.String.cuts ~sep:"\n" t.install); 215 + pr " end\n"; 216 + pr "\n"; 217 + pr " test do\n"; 218 + if t.test <> "" then 219 + List.iter 220 + (fun line -> if line = "" then pr "\n" else pr " %s\n" line) 221 + (Astring.String.cuts ~sep:"\n" t.test); 222 + pr " end\n"; 223 + pr "end\n"; 224 + Buffer.contents buf 185 225 186 - let detect_platform () = 187 - let os = 188 - match 189 - Bos.OS.Cmd.run_out Bos.Cmd.(v "uname" % "-s") |> Bos.OS.Cmd.out_string 190 - with 191 - | Ok (s, _) -> String.lowercase_ascii (String.trim s) 192 - | Error _ -> "unknown" 193 - in 194 - let arch = 195 - match 196 - Bos.OS.Cmd.run_out Bos.Cmd.(v "uname" % "-m") |> Bos.OS.Cmd.out_string 197 - with 198 - | Ok (s, _) -> String.trim s 199 - | Error _ -> "unknown" 200 - in 201 - match (os, arch) with 202 - | "darwin", "arm64" -> Arm64_sonoma 203 - | "darwin", "x86_64" -> Sonoma 204 - | "linux", "x86_64" -> X86_64_linux 205 - | "linux", "aarch64" -> Arm64_linux 206 - | _ -> Unknown (Fmt.str "%s_%s" os arch) 226 + (* Non-destructive in-place update of url/sha lines under platform markers. *) 207 227 208 - let platform_to_string = function 209 - | Arm64_sonoma -> "arm64_sonoma" 210 - | Sonoma -> "sonoma" 211 - | X86_64_linux -> "x86_64_linux" 212 - | Arm64_linux -> "arm64_linux" 213 - | Unknown s -> s 228 + let line_indent line = 229 + let trimmed = Astring.String.trim line in 230 + let diff = String.length line - String.length trimmed in 231 + String.make diff ' ' 214 232 215 - (* {1 SHA256} *) 233 + let replace_url_sha ~marker ~new_url ~sha256 lines = 234 + let rec go acc found = function 235 + | [] -> List.rev acc 236 + | line :: rest 237 + when found = 0 && Astring.String.is_infix ~affix:marker line -> 238 + go (line :: acc) 1 rest 239 + | line :: rest 240 + when found = 1 && Astring.String.is_infix ~affix:"url " line -> 241 + let new_line = Fmt.str "%surl \"%s\"" (line_indent line) new_url in 242 + go (new_line :: acc) 2 rest 243 + | line :: rest 244 + when found = 2 && Astring.String.is_infix ~affix:"sha256" line -> 245 + let new_line = Fmt.str "%ssha256 \"%s\"" (line_indent line) sha256 in 246 + go (new_line :: acc) 0 rest 247 + | line :: rest -> go (line :: acc) found rest 248 + in 249 + go [] 0 lines 216 250 217 - let sha256_file path = 218 - match Bos.OS.File.read (Fpath.v path) with 219 - | Error (`Msg e) -> Error e 220 - | Ok content -> 221 - let hash = Digestif.SHA256.digest_string content in 222 - Ok (Digestif.SHA256.to_hex hash) 251 + let replace_version ~version lines = 252 + List.map 253 + (fun line -> 254 + if 255 + Astring.String.is_infix ~affix:"version " line 256 + && Astring.String.is_infix ~affix:"\"" line 257 + then Fmt.str "%sversion \"%s\"" (line_indent line) version 258 + else line) 259 + lines 223 260 224 - (* {1 Building} *) 261 + let marker_for_platform (p : Platform.t) = 262 + match p with 263 + | Arm64_sonoma -> Some "on_arm do" 264 + | Sonoma -> Some "on_intel do" 265 + | X86_64_linux | Arm64_linux -> Some "on_linux do" 266 + | Unknown _ -> None 225 267 226 - let git_head_short () = 227 - let read_file path = 268 + let update_url_sha_in_file ~path ~updates ~version = 228 269 match Bos.OS.File.read (Fpath.v path) with 229 - | Ok s -> Some (String.trim s) 230 - | Error _ -> None 231 - in 232 - match read_file ".git/HEAD" with 233 - | Some s when Astring.String.is_prefix ~affix:"ref: " s -> ( 234 - let ref_path = ".git/" ^ Astring.String.drop ~max:5 s in 235 - match read_file ref_path with 236 - | Some hex when String.length hex >= 7 -> String.sub hex 0 7 237 - | _ -> "unknown") 238 - | Some hex when String.length hex >= 7 -> String.sub hex 0 7 239 - | _ -> "unknown" 270 + | Error (`Msg e) -> Error e 271 + | Ok content -> 272 + let lines = ref (Astring.String.cuts ~sep:"\n" content) in 273 + List.iter 274 + (fun (platform, new_url, sha256) -> 275 + match marker_for_platform platform with 276 + | None -> () 277 + | Some marker -> 278 + lines := replace_url_sha ~marker ~new_url ~sha256 !lines) 279 + updates; 280 + lines := replace_version ~version !lines; 281 + Bos.OS.File.write (Fpath.v path) (String.concat "\n" !lines) 282 + |> Result.map_error (fun (`Msg e) -> e) 283 + end 240 284 241 - let version_string () = 242 - let t = Unix.localtime (Unix.gettimeofday ()) in 243 - let date = 244 - Fmt.str "%04d%02d%02d" (1900 + t.tm_year) (1 + t.tm_mon) t.tm_mday 245 - in 246 - let hash = git_head_short () in 247 - Fmt.str "%s-%s" date hash 285 + module Tap = struct 286 + let name_of_url url = 287 + match Astring.String.cut ~rev:true ~sep:"/" url with 288 + | Some (base, repo) -> 289 + let repo = 290 + match Astring.String.cut ~sep:".git" repo with 291 + | Some (r, _) -> r 292 + | None -> repo 293 + in 294 + let repo = 295 + match Astring.String.cut ~sep:"homebrew-" repo with 296 + | Some (_, r) -> r 297 + | None -> repo 298 + in 299 + let user = 300 + match Astring.String.cut ~rev:true ~sep:"/" base with 301 + | Some (_, u) -> u 302 + | None -> base 303 + in 304 + Fmt.str "%s/%s" user repo 305 + | None -> url 248 306 249 - let exe config (pkg : package) = 250 - ignore config; 251 - let path = Fmt.str "_build/default/%s" pkg.target in 252 - let fpath = Fpath.v path in 253 - if Bos.OS.File.exists fpath = Ok true then Some path else None 307 + let set_push_url ~local_path ~push_url = 308 + Bos.OS.Cmd.run 309 + Bos.Cmd.( 310 + v "git" % "-C" % local_path % "remote" % "set-url" % "--push" % "origin" 311 + % push_url) 312 + |> Result.map_error (fun (`Msg e) -> e) 313 + |> Result.map (fun _ -> ()) 254 314 255 - let build_one config (pkg : package) platform version = 256 - let platform_str = platform_to_string platform in 257 - let bottle_name = 258 - Fmt.str "%s-%s.%s.bottle.tar.gz" pkg.name version platform_str 259 - in 260 - let bottle_dir = 261 - Fpath.(v config.build_dir / Fmt.str "%s-%s" pkg.name version) 262 - in 263 - match exe config pkg with 264 - | None -> 265 - Log.warn (fun m -> m "Executable not found for %s, skipping" pkg.name); 266 - Ok None 267 - | Some exe -> 268 - let open Result in 269 - let ( let* ) = bind in 270 - let* _ = 271 - Bos.OS.Dir.create bottle_dir |> Result.map_error (fun (`Msg e) -> e) 272 - in 273 - let* _ = 274 - Bos.OS.Cmd.run 275 - Bos.Cmd.(v "cp" % exe % Fpath.to_string Fpath.(bottle_dir / pkg.name)) 276 - |> Result.map_error (fun (`Msg e) -> e) 277 - in 278 - let* _ = 279 - Bos.OS.Cmd.run 315 + let ensure ~local_path ~clone_url ~push_url = 316 + let open Result in 317 + let ( let* ) = bind in 318 + let tap_path = Fpath.v local_path in 319 + if Bos.OS.Dir.exists tap_path = Ok true then ( 320 + Log.info (fun m -> m "Pulling tap..."); 321 + let has_remote_commits = 322 + Bos.OS.Cmd.run_status 280 323 Bos.Cmd.( 281 - v "chmod" % "+x" % Fpath.to_string Fpath.(bottle_dir / pkg.name)) 282 - |> Result.map_error (fun (`Msg e) -> e) 283 - in 284 - let bottle_path = Fpath.(v config.build_dir / bottle_name) in 285 - let* _ = 286 - Bos.OS.Cmd.run 287 - Bos.Cmd.( 288 - v "tar" % "czf" 289 - % Fpath.to_string bottle_path 290 - % "-C" % config.build_dir 291 - % Fmt.str "%s-%s" pkg.name version) 292 - |> Result.map_error (fun (`Msg e) -> e) 324 + v "git" % "-C" % local_path % "rev-parse" % "--verify" 325 + % "origin/main") 326 + = Ok (`Exited 0) 293 327 in 328 + let* () = set_push_url ~local_path ~push_url in 329 + if has_remote_commits then 330 + let* _ = 331 + Bos.OS.Cmd.run 332 + Bos.Cmd.(v "git" % "-C" % local_path % "pull" % "--ff-only") 333 + |> Result.map_error (fun (`Msg e) -> e) 334 + in 335 + Ok () 336 + else ( 337 + Log.info (fun m -> m "Remote tap is empty — skipping pull"); 338 + Ok ())) 339 + else ( 340 + Log.info (fun m -> m "Cloning tap..."); 294 341 let* _ = 295 - Bos.OS.Dir.delete ~recurse:true bottle_dir 342 + Bos.OS.Cmd.run Bos.Cmd.(v "git" % "clone" % clone_url % local_path) 296 343 |> Result.map_error (fun (`Msg e) -> e) 297 344 in 298 - Log.info (fun m -> m "Built: %s" (Fpath.to_string bottle_path)); 299 - Ok (Some (Fpath.to_string bottle_path)) 345 + let* () = set_push_url ~local_path ~push_url in 346 + Ok ()) 300 347 301 - let build_packages config names = 302 - let open Result in 303 - let ( let* ) = bind in 304 - let platform = detect_platform () in 305 - let version = version_string () in 306 - Log.info (fun m -> 307 - m "Building for platform: %s" (platform_to_string platform)); 308 - let* _ = 309 - Bos.OS.Dir.create (Fpath.v config.build_dir) 310 - |> Result.map_error (fun (`Msg e) -> e) 311 - in 312 - let packages = 313 - match names with 314 - | [] -> config.packages 315 - | names -> 316 - List.filter (fun (p : package) -> List.mem p.name names) config.packages 317 - in 318 - let exe_targets = List.map (fun (pkg : package) -> pkg.target) packages in 319 - let* _ = 320 - Bos.OS.Cmd.run 321 - (List.fold_left Bos.Cmd.( % ) 322 - Bos.Cmd.( 323 - v "opam" % "exec" % "--" % "dune" % "build" % "--profile=release") 324 - exe_targets) 325 - |> Result.map_error (fun (`Msg e) -> e) 326 - in 327 - let results = 328 - List.filter_map 329 - (fun pkg -> 330 - match build_one config pkg platform version with 331 - | Ok (Some path) -> Some (Ok path) 332 - | Ok None -> None 333 - | Error e -> Some (Error e)) 334 - packages 335 - in 336 - let errors, paths = 337 - List.fold_left 338 - (fun (errs, paths) r -> 339 - match r with Ok p -> (errs, p :: paths) | Error e -> (e :: errs, paths)) 340 - ([], []) results 341 - in 342 - match errors with 343 - | [] -> Ok (List.rev paths) 344 - | errs -> Error (String.concat "\n" errs) 345 - 346 - (* {1 Uploading} *) 347 - 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 = 352 - Fmt.str "https://%s.s3.%s.scw.cloud" config.storage.bucket 353 - config.storage.region 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 - 390 - let stage_file staging_dir file = 391 - let open Result in 392 - let ( let* ) = bind in 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 348 + let commit_and_push ~local_path ~message = 349 + let open Result in 350 + let ( let* ) = bind in 351 + let dir = local_path in 352 + let has_changes = 353 + match 354 + Bos.OS.Cmd.run_out 355 + Bos.Cmd.(v "git" % "-C" % dir % "status" % "--porcelain") 356 + |> Bos.OS.Cmd.out_string 357 + with 358 + | Ok (s, _) -> String.trim s <> "" 359 + | Error _ -> false 360 + in 361 + if not has_changes then ( 362 + Log.info (fun m -> m "No changes to push."); 363 + Ok ()) 364 + else 399 365 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)) 366 + Bos.OS.Cmd.run Bos.Cmd.(v "git" % "-C" % dir % "add" % "-A") 407 367 |> Result.map_error (fun (`Msg e) -> e) 408 368 in 409 369 let* _ = 410 370 Bos.OS.Cmd.run 411 - Bos.Cmd.(v "cp" % file % Fpath.to_string Fpath.(subdir / latest)) 371 + Bos.Cmd.(v "git" % "-C" % dir % "commit" % "-m" % message) 412 372 |> Result.map_error (fun (`Msg e) -> e) 413 373 in 414 - Log.info (fun m -> m "Staging: %s/%s/{%s,%s}" name platform_str versioned latest); 415 - Ok () 416 - 417 - let discover_bottles build_dir = function 418 - | [] -> ( 419 - match Bos.OS.Dir.contents (Fpath.v build_dir) with 420 - | Ok paths -> 421 - List.filter_map 422 - (fun p -> 423 - let s = Fpath.to_string p in 424 - if Astring.String.is_suffix ~affix:".tar.gz" s then Some s 425 - else None) 426 - paths 427 - | Error _ -> []) 428 - | files -> files 429 - 430 - let upload config files = 431 - let open Result in 432 - let ( let* ) = bind in 433 - let files = discover_bottles config.build_dir files in 434 - if files = [] then Ok () 435 - else 436 - let staging_dir = Fpath.(v config.build_dir / "_upload_staging") in 437 - let* _ = 438 - Bos.OS.Dir.create staging_dir |> Result.map_error (fun (`Msg e) -> e) 439 - in 440 - let* () = 441 - List.fold_left 442 - (fun acc file -> 443 - let* () = acc in 444 - stage_file staging_dir file) 445 - (Ok ()) files 446 - in 447 - let remote = config.storage.rclone_remote in 448 - let bucket = config.storage.bucket in 449 - Log.info (fun m -> 450 - m "Uploading %d files in parallel..." (List.length files * 2)); 451 - let* _ = 452 - Bos.OS.Cmd.run 453 - Bos.Cmd.( 454 - v "rclone" % "copy" % "--transfers" % "16" % "--checkers" % "16" 455 - % "--s3-upload-concurrency" % "8" % "--s3-acl" % "public-read" 456 - % Fpath.to_string staging_dir 457 - % Fmt.str "%s:%s" remote bucket) 458 - |> Result.map_error (fun (`Msg e) -> e) 459 - in 460 - let* _ = 461 - Bos.OS.Dir.delete ~recurse:true staging_dir 462 - |> Result.map_error (fun (`Msg e) -> e) 463 - in 464 - List.iter 465 - (fun 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 -> ()) 472 - files; 473 - Ok () 474 - 475 - (* {1 Formula Generation} *) 476 - 477 - let capitalize_first s = 478 - if String.length s = 0 then s 479 - else 480 - let first = Char.uppercase_ascii s.[0] in 481 - String.make 1 first ^ String.sub s 1 (String.length s - 1) 482 - 483 - let class_name name = 484 - let parts = Astring.String.cuts ~sep:"-" name in 485 - String.concat "" (List.map capitalize_first parts) 486 - 487 - let formula_bottle_urls buf config (pkg : package) = 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 490 - pr " on_macos do\n"; 491 - pr " on_arm do\n"; 492 - pr " url \"%s\"\n" (url Arm64_sonoma); 493 - pr " sha256 :no_check\n"; 494 - pr " end\n"; 495 - pr " on_intel do\n"; 496 - pr " url \"%s\"\n" (url Sonoma); 497 - pr " sha256 :no_check\n"; 498 - pr " end\n"; 499 - pr " end\n"; 500 - pr "\n"; 501 - pr " on_linux do\n"; 502 - pr " url \"%s\"\n" (url X86_64_linux); 503 - pr " sha256 :no_check\n"; 504 - pr " end\n" 505 - 506 - let formula_head_section buf config (pkg : package) = 507 - let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 508 - pr " head \"%s\", branch: \"main\"\n" config.mono_url; 509 - pr "\n"; 510 - pr " head do\n"; 511 - pr " depends_on \"ocaml\" => :build\n"; 512 - pr " depends_on \"opam\" => :build\n"; 513 - pr " depends_on \"dune\" => :build\n"; 514 - List.iter 515 - (fun (dep : head_dep) -> 516 - let typ = 517 - match dep.dep_type with 518 - | `Build -> ":build" 519 - | `Recommended -> ":recommended" 520 - in 521 - pr " depends_on \"%s\" => %s\n" dep.dep_name typ) 522 - pkg.head_deps; 523 - pr " end\n" 524 - 525 - let formula_install_section buf (pkg : package) = 526 - let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 527 - pr " def install\n"; 528 - pr " if build.head?\n"; 529 - pr 530 - " system \"opam\", \"init\", \"--disable-sandboxing\", \ 531 - \"--no-setup\", \"-y\" unless File.exist?(\"#{Dir.home}/.opam\")\n"; 532 - pr 533 - " system \"opam\", \"install\", \".\", \"--deps-only\", \ 534 - \"--with-test=false\", \"-y\", \"--working-dir\"\n"; 535 - pr " system \"opam\", \"exec\", \"--\", \"dune\", \"build\", \"%s\"\n" 536 - pkg.target; 537 - pr " bin.install \"_build/default/%s\" => \"%s\"\n" pkg.target pkg.name; 538 - pr " else\n"; 539 - pr " bin.install \"%s\"\n" pkg.name; 540 - pr " end\n"; 541 - pr " end\n" 542 - 543 - let generate_formula config (pkg : package) = 544 - let homepage = 545 - if pkg.homepage <> "" then pkg.homepage 546 - else Fmt.str "https://tangled.org/%s/%s" config.handle pkg.name 547 - in 548 - let buf = Buffer.create 1024 in 549 - let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 550 - pr "class %s < Formula\n" (class_name pkg.name); 551 - pr " desc \"%s\"\n" pkg.description; 552 - pr " homepage \"%s\"\n" homepage; 553 - pr " license \"%s\"\n" config.license; 554 - pr " version \"latest\"\n"; 555 - List.iter 556 - (fun formula -> 557 - pr " conflicts_with \"%s\", because: \"both install a `%s` binary\"\n" 558 - formula pkg.name) 559 - pkg.conflicts_with; 560 - pr "\n"; 561 - formula_bottle_urls buf config pkg; 562 - pr "\n"; 563 - formula_head_section buf config pkg; 564 - pr "\n"; 565 - formula_install_section buf pkg; 566 - pr "\n"; 567 - pr " test do\n"; 568 - pr " system bin/\"%s\", \"--help\"\n" pkg.name; 569 - pr " end\n"; 570 - pr "end\n"; 571 - Buffer.contents buf 572 - 573 - let tap_name config = 574 - let url = config.tap.clone_url in 575 - match Astring.String.cut ~rev:true ~sep:"/" url with 576 - | Some (base, repo) -> 577 - let repo = 578 - match Astring.String.cut ~sep:".git" repo with 579 - | Some (r, _) -> r 580 - | None -> repo 581 - in 582 - let repo = 583 - match Astring.String.cut ~sep:"homebrew-" repo with 584 - | Some (_, r) -> r 585 - | None -> repo 586 - in 587 - let user = 588 - match Astring.String.cut ~rev:true ~sep:"/" base with 589 - | Some (_, u) -> u 590 - | None -> base 591 - in 592 - Fmt.str "%s/%s" user repo 593 - | None -> url 594 - 595 - let generate_readme config = 596 - let buf = Buffer.create 512 in 597 - let pr fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt in 598 - let tap_name = tap_name config in 599 - pr "# homebrew-monopam\n\n"; 600 - pr "Homebrew tap for OCaml tools.\n\n"; 601 - pr "## Installation\n\n"; 602 - pr "```bash\n"; 603 - pr "brew tap %s\n" tap_name; 604 - pr "```\n\n"; 605 - pr "## Available Formulas\n\n"; 606 - pr "| Formula | Description |\n"; 607 - pr "|---------|-------------|\n"; 608 - List.iter 609 - (fun (p : package) -> pr "| `%s` | %s |\n" p.name p.description) 610 - config.packages; 611 - pr "\n## Usage\n\n"; 612 - pr "```bash\n"; 613 - pr "# Install pre-built binaries\n"; 614 - pr "brew install %s\n" 615 - (String.concat " " (List.map (fun (p : package) -> p.name) config.packages)); 616 - pr "\n# Or build from source\n"; 617 - pr "brew install --HEAD %s\n" 618 - (match config.packages with p :: _ -> p.name | [] -> "NAME"); 619 - pr "```\n\n"; 620 - pr "## License\n\n"; 621 - pr "%s\n" config.license; 622 - Buffer.contents buf 623 - 624 - (* {1 Tap Management} *) 625 - 626 - let platform_of_string = function 627 - | "arm64_sonoma" -> Arm64_sonoma 628 - | "sonoma" -> Sonoma 629 - | "x86_64_linux" -> X86_64_linux 630 - | "arm64_linux" -> Arm64_linux 631 - | s -> Unknown s 632 - 633 - let line_indent line = 634 - let trimmed = Astring.String.trim line in 635 - let diff = String.length line - String.length trimmed in 636 - String.make diff ' ' 637 - 638 - let replace_url_sha ~marker ~new_url ~sha256 lines = 639 - let rec go acc found = function 640 - | [] -> List.rev acc 641 - | line :: rest when found = 0 && Astring.String.is_infix ~affix:marker line 642 - -> 643 - go (line :: acc) 1 rest 644 - | line :: rest when found = 1 && Astring.String.is_infix ~affix:"url " line 645 - -> 646 - let new_line = Fmt.str "%surl \"%s\"" (line_indent line) new_url in 647 - go (new_line :: acc) 2 rest 648 - | line :: rest 649 - when found = 2 && Astring.String.is_infix ~affix:"sha256" line -> 650 - let new_line = Fmt.str "%ssha256 \"%s\"" (line_indent line) sha256 in 651 - go (new_line :: acc) 0 rest 652 - | line :: rest -> go (line :: acc) found rest 653 - in 654 - go [] 0 lines 655 - 656 - let replace_version ~version lines = 657 - List.map 658 - (fun line -> 659 - if 660 - Astring.String.is_infix ~affix:"version " line 661 - && Astring.String.is_infix ~affix:"\"" line 662 - then Fmt.str "%sversion \"%s\"" (line_indent line) version 663 - else line) 664 - lines 665 - 666 - let update_formula_checksums config tap_dir checksums = 667 - let open Result in 668 - let ( let* ) = bind in 669 - let update_one (name, sha256, platform) = 670 - let formula_path = Fpath.(v tap_dir / "Formula" / (name ^ ".rb")) in 671 - match Bos.OS.File.read formula_path with 672 - | Error (`Msg e) -> 673 - Log.warn (fun m -> m "Formula not found for %s: %s" name e); 674 - Ok () 675 - | Ok content -> ( 676 - let platform_str = platform_to_string platform in 677 - Log.info (fun m -> 678 - m "%s (%s): %s..." name platform_str 679 - (String.sub sha256 0 (min 16 (String.length sha256)))); 680 - let version = version_string () in 681 - let new_url = 682 - Fmt.str "%s/%s/%s/%s.bottle.tar.gz" (bottle_base_url config) name 683 - platform_str version 684 - in 685 - let marker = 686 - match platform with 687 - | Arm64_sonoma -> Some "on_arm do" 688 - | Sonoma -> Some "on_intel do" 689 - | X86_64_linux | Arm64_linux -> Some "on_linux do" 690 - | Unknown _ -> None 691 - in 692 - match marker with 693 - | None -> Ok () 694 - | Some marker -> 695 - let lines = Astring.String.cuts ~sep:"\n" content in 696 - let lines = replace_url_sha ~marker ~new_url ~sha256 lines in 697 - let lines = replace_version ~version lines in 698 - let* _ = 699 - Bos.OS.File.write formula_path (String.concat "\n" lines) 700 - |> Result.map_error (fun (`Msg e) -> e) 701 - in 702 - Ok ()) 703 - in 704 - let rec update_all = function 705 - | [] -> Ok () 706 - | c :: rest -> 707 - let* () = update_one c in 708 - update_all rest 709 - in 710 - update_all checksums 711 - 712 - (* {1 Release} *) 713 - 714 - let set_push_url config = 715 - Bos.OS.Cmd.run 716 - Bos.Cmd.( 717 - v "git" % "-C" % config.tap.local_path % "remote" % "set-url" % "--push" 718 - % "origin" % config.tap.push_url) 719 - |> Result.map_error (fun (`Msg e) -> e) 720 - |> Result.map (fun _ -> ()) 721 - 722 - let ensure_tap config = 723 - let open Result in 724 - let ( let* ) = bind in 725 - let tap_path = Fpath.v config.tap.local_path in 726 - if Bos.OS.Dir.exists tap_path = Ok true then ( 727 - Log.info (fun m -> m "Pulling tap..."); 728 - let has_remote_commits = 729 - Bos.OS.Cmd.run_status 730 - Bos.Cmd.( 731 - v "git" % "-C" % config.tap.local_path % "rev-parse" % "--verify" 732 - % "origin/main") 733 - = Ok (`Exited 0) 734 - in 735 - let* () = set_push_url config in 736 - if has_remote_commits then 737 374 let* _ = 738 - Bos.OS.Cmd.run 739 - Bos.Cmd.( 740 - v "git" % "-C" % config.tap.local_path % "pull" % "--ff-only") 375 + Bos.OS.Cmd.run Bos.Cmd.(v "git" % "-C" % dir % "push") 741 376 |> Result.map_error (fun (`Msg e) -> e) 742 377 in 378 + Log.info (fun m -> m "Tap pushed successfully."); 743 379 Ok () 744 - else ( 745 - Log.info (fun m -> m "Remote tap is empty — skipping pull"); 746 - Ok ())) 747 - else ( 748 - Log.info (fun m -> m "Cloning tap..."); 749 - let* _ = 750 - Bos.OS.Cmd.run 751 - Bos.Cmd.( 752 - v "git" % "clone" % config.tap.clone_url % config.tap.local_path) 753 - |> Result.map_error (fun (`Msg e) -> e) 754 - in 755 - let* () = set_push_url config in 756 - Ok ()) 757 - 758 - let commit_and_push_tap config = 759 - let open Result in 760 - let ( let* ) = bind in 761 - let dir = config.tap.local_path in 762 - let has_changes = 763 - match 764 - Bos.OS.Cmd.run_out 765 - Bos.Cmd.(v "git" % "-C" % dir % "status" % "--porcelain") 766 - |> Bos.OS.Cmd.out_string 767 - with 768 - | Ok (s, _) -> String.trim s <> "" 769 - | Error _ -> false 770 - in 771 - if not has_changes then ( 772 - Log.info (fun m -> m "No changes to push."); 773 - Ok ()) 774 - else 775 - let version = version_string () in 776 - let* _ = 777 - Bos.OS.Cmd.run Bos.Cmd.(v "git" % "-C" % dir % "add" % "-A") 778 - |> Result.map_error (fun (`Msg e) -> e) 779 - in 780 - let* _ = 781 - Bos.OS.Cmd.run 782 - Bos.Cmd.( 783 - v "git" % "-C" % dir % "commit" % "-m" 784 - % Fmt.str "Update bottles %s" version) 785 - |> Result.map_error (fun (`Msg e) -> e) 786 - in 787 - let* _ = 788 - Bos.OS.Cmd.run Bos.Cmd.(v "git" % "-C" % dir % "push") 789 - |> Result.map_error (fun (`Msg e) -> e) 790 - in 791 - Log.info (fun m -> m "Tap pushed successfully."); 792 - Ok () 793 - 794 - let timed name f = 795 - let bar = Tty.Progress.v ~color:(`Hex (0x19, 0x96, 0xf3)) ~total:1 name in 796 - let t0 = Unix.gettimeofday () in 797 - let result = f () in 798 - let t1 = Unix.gettimeofday () in 799 - Tty.Progress.finish ~message:(Fmt.str "%.1fs" (t1 -. t0)) bar; 800 - result 801 - 802 - let bottle_checksums bottles = 803 - List.filter_map 804 - (fun path -> 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)) 813 - bottles 814 - 815 - let ensure_formulas config = 816 - let open Result in 817 - let ( let* ) = bind in 818 - let formula_dir = Fpath.(v config.tap.local_path / "Formula") in 819 - let* _ = 820 - Bos.OS.Dir.create formula_dir |> Result.map_error (fun (`Msg e) -> e) 821 - in 822 - List.iter 823 - (fun (pkg : package) -> 824 - let formula_path = Fpath.(formula_dir / (pkg.name ^ ".rb")) in 825 - if Bos.OS.File.exists formula_path <> Ok true then ( 826 - Log.info (fun m -> m "Generating formula for %s" pkg.name); 827 - let content = generate_formula config pkg in 828 - ignore (Bos.OS.File.write formula_path content))) 829 - config.packages; 830 - Ok () 831 - 832 - let release config names = 833 - let open Result in 834 - let ( let* ) = bind in 835 - let* bottles = 836 - timed "Step 1: Building" (fun () -> build_packages config names) 837 - in 838 - let* () = timed "Step 2: Uploading" (fun () -> upload config bottles) in 839 - let* () = 840 - timed "Step 3: Updating tap" (fun () -> 841 - let* () = ensure_tap config in 842 - let checksums = bottle_checksums bottles in 843 - let* () = ensure_formulas config in 844 - let* () = 845 - update_formula_checksums config config.tap.local_path checksums 846 - in 847 - let readme_path = Fpath.(v config.tap.local_path / "README.md") in 848 - let* _ = 849 - Bos.OS.File.write readme_path (generate_readme config) 850 - |> Result.map_error (fun (`Msg e) -> e) 851 - in 852 - commit_and_push_tap config) 853 - in 854 - Log.app (fun m -> 855 - m "Release complete. Bottles at: %s" (bottle_base_url config)); 856 - Ok () 857 - 858 - (* {1 Credential Setup} *) 859 - 860 - let check_cmd name = 861 - match 862 - Bos.OS.Cmd.run_out Bos.Cmd.(v "command" % "-v" % name) 863 - |> Bos.OS.Cmd.out_string 864 - with 865 - | Ok (path, _) when String.trim path <> "" -> Ok (String.trim path) 866 - | _ -> err_not_found name 867 - 868 - let check_tool name = 869 - match check_cmd name with 870 - | Ok path -> 871 - Log.app (fun m -> m " %s: %s" name path); 872 - Ok () 873 - | Error e -> 874 - Log.err (fun m -> m " %s: MISSING" name); 875 - Error e 876 - 877 - let rclone_remote_exists remote = 878 - match 879 - Bos.OS.Cmd.run_out Bos.Cmd.(v "rclone" % "listremotes") 880 - |> Bos.OS.Cmd.out_string 881 - with 882 - | Ok (out, _) -> 883 - let remotes = Astring.String.cuts ~sep:"\n" out in 884 - List.exists 885 - (fun r -> 886 - Astring.String.is_prefix ~affix:(remote ^ ":") (String.trim r)) 887 - remotes 888 - | Error _ -> false 889 - 890 - let doctor config = 891 - let open Result in 892 - let ( let* ) = bind in 893 - Log.app (fun m -> m "Checking tools..."); 894 - let* () = check_tool "rclone" in 895 - let* () = check_tool "scw" in 896 - let* () = check_tool "git" in 897 - let* () = check_tool "opam" in 898 - let* () = check_tool "dune" in 899 - Log.app (fun m -> 900 - m "Checking rclone remote '%s'..." config.storage.rclone_remote); 901 - let rclone_ok = rclone_remote_exists config.storage.rclone_remote in 902 - if rclone_ok then 903 - Log.app (fun m -> m " rclone remote '%s': OK" config.storage.rclone_remote) 904 - else ( 905 - Log.warn (fun m -> 906 - m " rclone remote '%s': NOT CONFIGURED" config.storage.rclone_remote); 907 - Log.warn (fun m -> m " Run 'bottler login' to set up credentials.")); 908 - Log.app (fun m -> m "Checking storage..."); 909 - Log.app (fun m -> 910 - m " Bucket: %s (region: %s)" config.storage.bucket config.storage.region); 911 - Log.app (fun m -> m " URL: %s" (bottle_base_url config)); 912 - Log.app (fun m -> m "Checking tap..."); 913 - let tap_exists = 914 - Bos.OS.Dir.exists (Fpath.v config.tap.local_path) = Ok true 915 - in 916 - if tap_exists then 917 - Log.app (fun m -> m " Tap checkout: %s (OK)" config.tap.local_path) 918 - else 919 - Log.warn (fun m -> 920 - m " Tap checkout: %s (will be cloned on first release)" 921 - config.tap.local_path); 922 - Log.app (fun m -> m "Platform: %s" (platform_to_string (detect_platform ()))); 923 - Ok () 924 - 925 - let ensure_scw_profile profile = 926 - let profile_exists = 927 - Bos.OS.Cmd.run_status 928 - Bos.Cmd.( 929 - v "scw" % "config" % "get" % "access-key" % "--profile" % profile) 930 - = Ok (`Exited 0) 931 - in 932 - if profile_exists then ( 933 - Log.app (fun m -> m "Scaleway profile '%s' already configured." profile); 934 - Ok ()) 935 - else ( 936 - Log.app (fun m -> m "Setting up Scaleway profile '%s'..." profile); 937 - Log.app (fun m -> m ""); 938 - Log.app (fun m -> 939 - m "Get your credentials from: https://console.scaleway.com/iam/api-keys"); 940 - Log.app (fun m -> m ""); 941 - Bos.OS.Cmd.run Bos.Cmd.(v "scw" % "init" % "--profile" % profile) 942 - |> Result.map_error (fun (`Msg e) -> e) 943 - |> Result.map (fun _ -> ())) 944 - 945 - let login config = 946 - let open Result in 947 - let ( let* ) = bind in 948 - let* _scw = check_cmd "scw" in 949 - let* _rclone = check_cmd "rclone" in 950 - let profile = config.storage.profile in 951 - let* () = ensure_scw_profile profile in 952 - Log.app (fun m -> m "Generating rclone configuration..."); 953 - let* _ = 954 - Bos.OS.Cmd.run 955 - Bos.Cmd.( 956 - v "scw" % "object" % "config" % "install" % "type=rclone" % "--profile" 957 - % profile) 958 - |> Result.map_error (fun (`Msg e) -> e) 959 - in 960 - Log.app (fun m -> m "Ensuring bucket '%s' exists..." config.storage.bucket); 961 - let _ = 962 - Bos.OS.Cmd.run 963 - Bos.Cmd.( 964 - v "scw" % "object" % "bucket" % "create" 965 - % Fmt.str "name=%s" config.storage.bucket 966 - % Fmt.str "region=%s" config.storage.region 967 - % "--profile" % profile) 968 - in 969 - Log.app (fun m -> m ""); 970 - Log.app (fun m -> m "Login complete. Run 'bottler doctor' to verify."); 971 - Ok () 380 + end
+95 -103
lib/homebrew.mli
··· 3 3 SPDX-License-Identifier: ISC 4 4 ---------------------------------------------------------------------------*) 5 5 6 - (** Homebrew bottle builder and tap manager for OCaml monorepos. *) 6 + (** Generic Homebrew primitives: platform detection, bottle tarball conventions, 7 + Ruby formula emission, and tap repository operations. 7 8 8 - (** {1 Configuration} *) 9 + This library is deliberately free of any monorepo-specific configuration, 10 + YAML parsing, S3 / Scaleway details, or build orchestration. It provides 11 + just the Homebrew-flavoured building blocks. Tools like [bottler] compose 12 + these with their own config and distribution logic. 9 13 10 - type storage = { 11 - bucket : string; 12 - region : string; 13 - profile : string; 14 - endpoint : string; 15 - rclone_remote : string; 16 - } 17 - (** Storage configuration for S3-compatible object storage. *) 14 + Could also be reused by opam or dune-pkg to emit Homebrew formulas for OCaml 15 + packages. *) 18 16 19 - type tap = { clone_url : string; push_url : string; local_path : string } 20 - (** Tap repository configuration. *) 17 + (** {1 Platform detection} *) 21 18 22 - type head_dep = { dep_name : string; dep_type : [ `Build | `Recommended ] } 23 - (** Head-build dependency specification. *) 19 + module Platform : sig 20 + (** Homebrew platform identifiers, matching bottle filename suffixes. *) 21 + type t = 22 + | Arm64_sonoma 23 + | Sonoma 24 + | X86_64_linux 25 + | Arm64_linux 26 + | Unknown of string 24 27 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 28 + val detect : unit -> t 29 + (** Detect the current build platform via [uname]. *) 30 30 31 - type build = { linux : linux_mode } 32 - (** Build strategy. *) 31 + val to_string : t -> string 32 + (** Platform string used in bottle filenames and formula stanzas (e.g. 33 + ["arm64_sonoma"]). *) 33 34 34 - type package = { 35 - name : string; 36 - target : string; 37 - description : string; 38 - homepage : string; 39 - head_deps : head_dep list; 40 - conflicts_with : string list; 41 - } 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. *) 35 + val of_string : string -> t 36 + (** Parse a platform string. Unknown strings return [Unknown s]. *) 37 + end 45 38 46 - type config = { 47 - handle : string; 48 - mono_url : string; 49 - license : string; 50 - storage : storage; 51 - tap : tap; 52 - build : build; 53 - packages : package list; 54 - build_dir : string; 55 - } 56 - (** Top-level configuration. *) 39 + (** {1 Bottle tarballs} *) 57 40 58 - val config_jsont : config Jsont.t 59 - (** Jsont codec for {!config}. *) 60 - 61 - val load_config : string -> (config, string) result 62 - (** [load_config path] loads a YAML configuration file. *) 63 - 64 - val tap_name : config -> string 65 - (** [tap_name config] derives the Homebrew tap name (e.g. ["samoht/monopam"]). 66 - *) 67 - 68 - (** {1 Platform Detection} *) 69 - 70 - (** Build platform identifier. *) 71 - type platform = 72 - | Arm64_sonoma 73 - | Sonoma 74 - | X86_64_linux 75 - | Arm64_linux 76 - | Unknown of string 77 - 78 - val detect_platform : unit -> platform 79 - (** [detect_platform ()] detects the current build platform. *) 80 - 81 - val platform_to_string : platform -> string 82 - (** [platform_to_string p] returns the platform string for bottle names. *) 83 - 84 - (** {1 Building} *) 41 + module Bottle : sig 42 + val filename : 43 + package:string -> version:string -> platform:Platform.t -> string 44 + (** [filename ~package ~version ~platform] returns the standard bottle tarball 45 + filename, e.g. ["merlint-20260416-abc123.arm64_sonoma.bottle.tar.gz"]. *) 85 46 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. *) 47 + val parse_filename : string -> (string * string * Platform.t) option 48 + (** [parse_filename name] extracts [(package, version, platform)] from a 49 + bottle filename. Works with package names containing hyphens by splitting 50 + on the first hyphen whose successor is a digit. *) 89 51 90 - (** {1 Uploading} *) 52 + val sha256_file : string -> (string, string) result 53 + (** [sha256_file path] returns the SHA256 hex digest of [path]. *) 54 + end 91 55 92 - val upload : config -> string list -> (unit, string) result 93 - (** [upload config files] uploads bottle files to object storage under 94 - [{package}/{platform}/] prefixes. *) 56 + (** {1 Ruby formula generation} *) 95 57 96 - (** {1 Formula Generation} *) 58 + module Formula : sig 59 + type head_dep = { name : string; dep_type : [ `Build | `Recommended ] } 97 60 98 - val generate_formula : config -> package -> string 99 - (** [generate_formula config package] generates a Homebrew Ruby formula. *) 61 + type bottle = { 62 + platform : Platform.t; 63 + url : string; 64 + sha : [ `Digest of string | `No_check ]; 65 + } 100 66 101 - val generate_readme : config -> string 102 - (** [generate_readme config] generates a tap README.md. *) 67 + type head = { 68 + url : string; 69 + branch : string; 70 + deps : head_dep list; 71 + install : string; 72 + } 103 73 104 - (** {1 SHA256 Checksums} *) 74 + type t = { 75 + name : string; 76 + description : string; 77 + homepage : string; 78 + license : string; 79 + version : string; 80 + conflicts_with : string list; 81 + bottles : bottle list; 82 + head : head option; 83 + install : string; 84 + test : string; 85 + } 105 86 106 - val sha256_file : string -> (string, string) result 107 - (** [sha256_file path] computes the SHA256 hex digest of a file. *) 87 + val class_name : string -> string 88 + (** Convert a kebab-case formula name to a CamelCase Ruby class name. E.g. 89 + [class_name "mdns-query" = "MdnsQuery"]. *) 108 90 109 - (** {1 Tap Management} *) 91 + val to_ruby : t -> string 92 + (** Render a formula record to a Ruby formula file. *) 110 93 111 - val update_formula_checksums : 112 - config -> string -> (string * string * platform) list -> (unit, string) result 113 - (** [update_formula_checksums config tap_dir checksums] updates SHA256 checksums 114 - in formula files. [checksums] is a list of [(name, sha256, platform)] 115 - triples. *) 94 + val update_url_sha_in_file : 95 + path:string -> 96 + updates:(Platform.t * string * string) list -> 97 + version:string -> 98 + (unit, string) result 99 + (** [update_url_sha_in_file ~path ~updates ~version] rewrites existing bottle 100 + URLs and SHA256 lines inside the formula file at [path]. Each tuple is 101 + [(platform, new_url, sha256_hex)]. Also updates the [version] declaration. 102 + Non-destructive for other content. *) 103 + end 116 104 117 - val release : config -> string list -> (unit, string) result 118 - (** [release config names] performs a full release: build, upload, and update 119 - tap with SHA256 checksums. *) 105 + (** {1 Tap repositories} *) 120 106 121 - (** {1 Credential Setup} *) 107 + module Tap : sig 108 + val name_of_url : string -> string 109 + (** Derive a tap name (e.g. ["samoht/monopam"]) from a tap clone URL. *) 122 110 123 - val login : config -> (unit, string) result 124 - (** [login config] sets up Scaleway and rclone credentials for bottle uploads. 125 - Checks that [scw] and [rclone] are installed, configures the Scaleway CLI 126 - profile with interactive prompts for access key and secret key, then 127 - generates the rclone configuration from the Scaleway profile. *) 111 + val ensure : 112 + local_path:string -> 113 + clone_url:string -> 114 + push_url:string -> 115 + (unit, string) result 116 + (** Clone or fast-forward pull the tap repo at [local_path] and point its 117 + [origin] push URL at [push_url]. Safe on empty remotes. *) 128 118 129 - val doctor : config -> (unit, string) result 130 - (** [doctor config] checks that all required tools are available and credentials 131 - are configured. *) 119 + val commit_and_push : 120 + local_path:string -> message:string -> (unit, string) result 121 + (** Stage all changes, commit with [message], and push. No-op if the tree is 122 + clean. *) 123 + end
+122 -216
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 - packages: 12 - - name: myapp 13 - target: ocaml-myapp/bin/main.exe 14 - description: "A test application" 15 - homepage: https://example.com/myapp 16 - - name: mytool 17 - target: ocaml-mytool/bin/main.exe 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.failf "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) 35 - "rclone_remote" "scaleway" config.storage.rclone_remote; 36 - Alcotest.(check string) 37 - "clone_url" "https://example.com/tap.git" config.tap.clone_url; 38 - Alcotest.(check string) 39 - "push_url" "git@example.com:tap" config.tap.push_url; 40 - Alcotest.(check string) 41 - "local_path" "../homebrew-monopam" config.tap.local_path; 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; 50 - Alcotest.(check string) 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 56 - Alcotest.(check string) "dep name" "afl-fuzz" dep.dep_name 57 - 58 - let test_defaults () = 59 - let yaml = 60 - {| 61 - handle: alice.example 62 - storage: 63 - bucket: b 64 - region: r 65 - profile: p 66 - tap: 67 - clone_url: https://c 68 - push_url: git@p 69 - packages: [] 70 - |} 71 - in 72 - match Yamlt.decode_string Homebrew.config_jsont yaml with 73 - | Error e -> Alcotest.failf "parse error: %s" e 74 - | Ok config -> 75 - Alcotest.(check string) 76 - "mono_url" "https://tangled.org/alice.example/mono.git" config.mono_url; 77 - Alcotest.(check string) "license" "ISC" config.license; 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) 105 - 106 1 let test_platform_roundtrip () = 107 - let platforms = 2 + let all = 108 3 [ 109 - Homebrew.Arm64_sonoma; 4 + Homebrew.Platform.Arm64_sonoma; 110 5 Sonoma; 111 6 X86_64_linux; 112 7 Arm64_linux; ··· 115 10 in 116 11 List.iter 117 12 (fun p -> 118 - let s = Homebrew.platform_to_string p in 13 + let s = Homebrew.Platform.to_string p in 119 14 Alcotest.(check bool) 120 - (Fmt.str "platform string non-empty: %s" s) 15 + (Fmt.str "to_string non-empty: %s" s) 121 16 true 122 - (String.length s > 0)) 123 - platforms 17 + (String.length s > 0); 18 + match p with 19 + | Unknown _ -> () 20 + | _ -> 21 + Alcotest.(check bool) 22 + (Fmt.str "roundtrip: %s" s) 23 + true 24 + (Homebrew.Platform.of_string s = p)) 25 + all 124 26 125 - let test_config : Homebrew.config = 27 + let test_bottle_filename () = 28 + let name = 29 + Homebrew.Bottle.filename ~package:"merlint" ~version:"20260416-abcdef0" 30 + ~platform:Arm64_sonoma 31 + in 32 + Alcotest.(check string) 33 + "bottle filename" "merlint-20260416-abcdef0.arm64_sonoma.bottle.tar.gz" name 34 + 35 + let test_bottle_parse_simple () = 36 + let parsed = 37 + Homebrew.Bottle.parse_filename 38 + "merlint-20260416-abcdef0.arm64_sonoma.bottle.tar.gz" 39 + in 40 + match parsed with 41 + | Some (pkg, version, platform) -> 42 + Alcotest.(check string) "pkg" "merlint" pkg; 43 + Alcotest.(check string) "version" "20260416-abcdef0" version; 44 + Alcotest.(check bool) "platform" true (platform = Arm64_sonoma) 45 + | None -> Alcotest.fail "parse returned None" 46 + 47 + let test_bottle_parse_hyphen () = 48 + let parsed = 49 + Homebrew.Bottle.parse_filename 50 + "mdns-query-20260416-abcdef0.x86_64_linux.bottle.tar.gz" 51 + in 52 + match parsed with 53 + | Some (pkg, version, platform) -> 54 + Alcotest.(check string) "pkg" "mdns-query" pkg; 55 + Alcotest.(check string) "version" "20260416-abcdef0" version; 56 + Alcotest.(check bool) "platform" true (platform = X86_64_linux) 57 + | None -> Alcotest.fail "parse returned None" 58 + 59 + let test_class_name () = 60 + Alcotest.(check string) 61 + "single" "Merlint" 62 + (Homebrew.Formula.class_name "merlint"); 63 + Alcotest.(check string) 64 + "kebab" "MdnsQuery" 65 + (Homebrew.Formula.class_name "mdns-query"); 66 + Alcotest.(check string) 67 + "triple" "GitMonoTool" 68 + (Homebrew.Formula.class_name "git-mono-tool") 69 + 70 + let sample_formula () : Homebrew.Formula.t = 126 71 { 127 - handle = "test.example"; 128 - mono_url = "https://example.com/mono.git"; 72 + name = "myapp"; 73 + description = "A test app"; 74 + homepage = "https://example.com/myapp"; 129 75 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"; 76 + version = "latest"; 77 + conflicts_with = []; 78 + bottles = 79 + [ 80 + { 81 + platform = Arm64_sonoma; 82 + url = "https://example.com/myapp/arm64_sonoma/latest.bottle.tar.gz"; 83 + sha = `No_check; 84 + }; 85 + { 86 + platform = Sonoma; 87 + url = "https://example.com/myapp/sonoma/latest.bottle.tar.gz"; 88 + sha = `No_check; 89 + }; 90 + { 91 + platform = X86_64_linux; 92 + url = "https://example.com/myapp/x86_64_linux/latest.bottle.tar.gz"; 93 + sha = `No_check; 94 + }; 95 + ]; 96 + head = 97 + Some 98 + { 99 + url = "https://example.com/mono.git"; 100 + branch = "main"; 101 + deps = [ { name = "docker"; dep_type = `Recommended } ]; 102 + install = ""; 103 + }; 104 + install = "bin.install \"myapp\""; 105 + test = "system bin/\"myapp\", \"--help\""; 147 106 } 148 107 149 - let test_generate_formula () = 150 - let pkg : Homebrew.package = 151 - { 152 - name = "myapp"; 153 - target = "ocaml-myapp/bin/main.exe"; 154 - description = "My test app"; 155 - homepage = "https://example.com/myapp"; 156 - head_deps = [ { dep_name = "docker"; dep_type = `Recommended } ]; 157 - conflicts_with = []; 158 - } 159 - in 160 - let formula = Homebrew.generate_formula test_config pkg in 108 + let test_formula_ruby () = 109 + let formula = sample_formula () in 110 + let ruby = Homebrew.Formula.to_ruby formula in 161 111 Alcotest.(check bool) 162 - "has class" true 163 - (Astring.String.is_infix ~affix:"class Myapp < Formula" formula); 112 + "class" true 113 + (Astring.String.is_infix ~affix:"class Myapp < Formula" ruby); 164 114 Alcotest.(check bool) 165 - "has desc" true 166 - (Astring.String.is_infix ~affix:"desc \"My test app\"" formula); 115 + "desc" true 116 + (Astring.String.is_infix ~affix:"desc \"A test app\"" ruby); 167 117 Alcotest.(check bool) 168 - "has homepage" true 169 - (Astring.String.is_infix ~affix:"homepage \"https://example.com/myapp\"" 170 - formula); 118 + "arm64" true 119 + (Astring.String.is_infix ~affix:"/myapp/arm64_sonoma/" ruby); 171 120 Alcotest.(check bool) 172 - "has license" true 173 - (Astring.String.is_infix ~affix:"license \"ISC\"" formula); 121 + "linux" true 122 + (Astring.String.is_infix ~affix:"/myapp/x86_64_linux/" ruby); 174 123 Alcotest.(check bool) 175 - "has arm64_sonoma path" true 176 - (Astring.String.is_infix ~affix:"/myapp/arm64_sonoma/" formula); 177 - Alcotest.(check bool) 178 - "has sonoma path" true 179 - (Astring.String.is_infix ~affix:"/myapp/sonoma/" formula); 124 + "docker" true 125 + (Astring.String.is_infix ~affix:"depends_on \"docker\" => :recommended" ruby); 180 126 Alcotest.(check bool) 181 - "has linux path" true 182 - (Astring.String.is_infix ~affix:"/myapp/x86_64_linux/" formula); 183 - Alcotest.(check bool) 184 - "has docker dep" true 185 - (Astring.String.is_infix ~affix:"depends_on \"docker\" => :recommended" 186 - formula); 187 - Alcotest.(check bool) 188 - "has head build" true 189 - (Astring.String.is_infix ~affix:"head \"https://example.com/mono.git\"" 190 - formula); 191 - Alcotest.(check bool) 192 - "has install" true 193 - (Astring.String.is_infix ~affix:"bin.install \"myapp\"" formula); 194 - Alcotest.(check bool) 195 - "has test" true 196 - (Astring.String.is_infix ~affix:"system bin/\"myapp\", \"--help\"" formula) 197 - 198 - let test_generate_formula_kebab_case () = 199 - let pkg : Homebrew.package = 200 - { 201 - name = "mdns-query"; 202 - target = "ocaml-mdns/bin/mdns_query.exe"; 203 - description = "mDNS query tool"; 204 - homepage = ""; 205 - head_deps = []; 206 - conflicts_with = []; 207 - } 208 - in 209 - let formula = Homebrew.generate_formula test_config pkg in 127 + "head" true 128 + (Astring.String.is_infix ~affix:"head \"https://example.com/mono.git\"" ruby); 210 129 Alcotest.(check bool) 211 - "kebab to CamelCase" true 212 - (Astring.String.is_infix ~affix:"class MdnsQuery < Formula" formula) 130 + "test" true 131 + (Astring.String.is_infix ~affix:"system bin/\"myapp\", \"--help\"" ruby) 213 132 214 - let test_generate_formula_custom_exe () = 215 - let pkg : Homebrew.package = 216 - { 217 - name = "agent"; 218 - target = "ocaml-agent/bin/main.exe"; 219 - description = "Agent"; 220 - homepage = ""; 221 - head_deps = []; 222 - conflicts_with = []; 223 - } 224 - in 225 - let formula = Homebrew.generate_formula test_config pkg in 226 - Alcotest.(check bool) 227 - "uses custom exe_name for dune build" true 228 - (Astring.String.is_infix 229 - ~affix:"\"dune\", \"build\", \"ocaml-agent/bin/main.exe\"" formula); 230 - Alcotest.(check bool) 231 - "installs as binary name" true 232 - (Astring.String.is_infix ~affix:"=> \"agent\"" formula) 133 + let test_tap_name () = 134 + Alcotest.(check string) 135 + "github" "samoht/monopam" 136 + (Homebrew.Tap.name_of_url "https://github.com/samoht/homebrew-monopam.git"); 137 + Alcotest.(check string) 138 + "tangled" "gazagnaire.org/monopam" 139 + (Homebrew.Tap.name_of_url 140 + "https://tangled.org/gazagnaire.org/homebrew-monopam.git") 233 141 234 142 let test_sha256 () = 235 143 let tmp = Filename.temp_file "homebrew_test" ".txt" in 236 144 let oc = open_out tmp in 237 145 output_string oc "hello world\n"; 238 146 close_out oc; 239 - match Homebrew.sha256_file tmp with 147 + match Homebrew.Bottle.sha256_file tmp with 240 148 | Error e -> 241 149 Sys.remove tmp; 242 150 Alcotest.failf "sha256 error: %s" e ··· 249 157 let suite = 250 158 ( "homebrew", 251 159 [ 252 - Alcotest.test_case "config load" `Quick test_load_config; 253 - Alcotest.test_case "config defaults" `Quick test_defaults; 254 - Alcotest.test_case "build mode linuxbrew" `Quick test_build_mode_linuxbrew; 255 160 Alcotest.test_case "platform roundtrip" `Quick test_platform_roundtrip; 256 - Alcotest.test_case "formula generate" `Quick test_generate_formula; 257 - Alcotest.test_case "formula kebab-case" `Quick 258 - test_generate_formula_kebab_case; 259 - Alcotest.test_case "formula custom exe" `Quick 260 - test_generate_formula_custom_exe; 161 + Alcotest.test_case "bottle filename" `Quick test_bottle_filename; 162 + Alcotest.test_case "bottle parse simple" `Quick test_bottle_parse_simple; 163 + Alcotest.test_case "bottle parse hyphen" `Quick test_bottle_parse_hyphen; 164 + Alcotest.test_case "formula class_name" `Quick test_class_name; 165 + Alcotest.test_case "formula ruby" `Quick test_formula_ruby; 166 + Alcotest.test_case "tap name_of_url" `Quick test_tap_name; 261 167 Alcotest.test_case "sha256 file" `Quick test_sha256; 262 168 ] )