Homebrew 및 개인 GitHub 리포지토리

저는 최근에 조직에서 사용할 내부 CLI 도구를 개발했으며 물론 다른 개발자가 쉽게 설치할 수 있도록 하고 싶었습니다. 즉, Homebrew 에서 설치 가능하게 만들고 싶었습니다. 전부는 아니지만 대부분의 동료가 설치된 응용 프로그램을 관리하는 데 사용하기 때문입니다. GitHub 액세스 토큰으로 HOMEBREW_GITHUB_API_TOKEN 환경 변수를 설정하여 private repo를 탭할 수 있었지만 설치할 때 curl에서 404 오류가 발생했습니다. 검색을 통해 찾을 수 있는 문서와 정보는 존재하지 않거나 오래되었으므로 다른 사람들이 사용할 수 있도록 나에게 맞는 것을 얻을 수 있을 것이라고 생각했습니다.

배경



내가 작성한 도구는 go 으로 작성된 작은 CLI 도구입니다. 버전semver으로 main에 대한 커밋에 태그를 지정하면 CI 도구는 GoReleaser을 사용하여 다양한 아키텍처용 바이너리를 빌드하고 GitHub 릴리스를 만들고 Homebrew 수식을 업데이트합니다. GoReleaser는 확실히 필요하지 않지만 작업을 매우 쉽게 만듭니다. 참고로 제 .goreleaser.yaml는 다음과 같습니다.

before:
  hooks:
    # You may remove this if you don't use go modules.
    - go mod tidy
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - windows
      - darwin
    goarch:
      - amd64
      - arm64
    ignore:
      - goos: windows
        goarch: arm64
archives:
  -
    replacements:
      amd64: x86_64
      darwin: Darwin
      linux: Linux
    format_overrides:
      - goos: windows
        format: zip
brews:
  -
    tap:
      owner: myorg
      name: myrepo
    download_strategy: GitHubPrivateRepositoryReleaseDownloadStrategy
    custom_require: "lib/custom_download_strategy"
    commit_author:
      name: My Name
      email: [email protected]
    folder: HomebrewFormula
checksum:
  name_template: 'checksums.txt'
snapshot:
  name_template: "{{ incpatch .Version }}-next"
changelog:
  sort: asc
  filters:
    exclude:
      - '^docs:'
      - '^test:'


저는 Homebrew 수식에 대해 별도의 저장소를 만들지 않고 대신 HomebrewFormula 라는 디렉토리의 도구 저장소에 수식을 넣었습니다. 이렇게 하면 탭 명령이 조금 더 길어지지만 저는 괜찮습니다. 따라서 Homebrew와 함께 배포하려는 각 도구에 대해 추가 저장소가 필요하지 않습니다.

GoReleaser는 ${toolname}_${semver_without_leading_v}_${platform}_${arch}.tar.gz 와 같은 이름의 각 플랫폼/아키(Windows용 zip)에 대해 gzip을 생성합니다. 이것은 Homebrew 다운로드 전략에 중요합니다.

홈브류 소스



어떤 이유로든 유효한 액세스 토큰이 있어도 비공개 저장소에서 자산을 릴리스할 수 없습니다curl. 따라서 GitHub API를 사용하여 자산의 API URL을 가져오려면 몇 가지 코드가 필요합니다. 이것은 사용자 정의 다운로드 전략의 형태로 제공됩니다.

필요한 작업을 수행했지만 이동하거나 변경된 일부 Homebrew 기능을 사용하는 오래된 코드를 찾았습니다. 그래서 더 파고들고 시행착오를 겪은 후 현재 버전의 Homebrew(이 글을 쓰는 시점 기준 3.3.12)에서 작동하도록 할 수 있었습니다.
HomebrewFormula/lib/custom_download_strategy.rb
require "download_strategy"

# S3DownloadStrategy downloads tarballs from AWS S3.
# To use it, add `:using => :s3` to the URL section of your
# formula.  This download strategy uses AWS access tokens (in the
# environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`)
# to sign the request.  This strategy is good in a corporate setting,
# because it lets you use a private S3 bucket as a repo for internal
# distribution.  (It will work for public buckets as well.)
class S3DownloadStrategy < CurlDownloadStrategy
  def initialize(url, name, version, **meta)
    super
  end

  def _fetch(url:, resolved_url:, timeout:)
    if url !~ %r{^https?://([^.].*)\.s3\.amazonaws\.com/(.+)$} &&
       url !~ %r{^s3://([^.].*?)/(.+)$}
      raise "Bad S3 URL: " + url
    end

    bucket = Regexp.last_match(1)
    key = Regexp.last_match(2)

    ENV["AWS_ACCESS_KEY_ID"] = ENV["HOMEBREW_AWS_ACCESS_KEY_ID"]
    ENV["AWS_SECRET_ACCESS_KEY"] = ENV["HOMEBREW_AWS_SECRET_ACCESS_KEY"]

    begin
      signer = Aws::S3::Presigner.new
      s3url = signer.presigned_url :get_object, bucket: bucket, key: key
    rescue Aws::Sigv4::Errors::MissingCredentialsError
      ohai "AWS credentials missing, trying public URL instead."
      s3url = url
    end

    curl_download s3url, to: temporary_path
  end
end

# GitHubPrivateRepositoryDownloadStrategy downloads contents from GitHub
# Private Repository. To use it, add
# `:using => :github_private_repo` to the URL section of
# your formula. This download strategy uses GitHub access tokens (in the
# environment variables `HOMEBREW_GITHUB_API_TOKEN`) to sign the request.  This
# strategy is suitable for corporate use just like S3DownloadStrategy, because
# it lets you use a private GitHub repository for internal distribution.  It
# works with public one, but in that case simply use CurlDownloadStrategy.
class GitHubPrivateRepositoryDownloadStrategy < CurlDownloadStrategy
  require "utils/formatter"
  require "utils/github"

  def initialize(url, name, version, **meta)
    super
    parse_url_pattern
    set_github_token
  end

  def parse_url_pattern
    unless match = url.match(%r{https://github.com/([^/]+)/([^/]+)/(\S+)})
      raise CurlDownloadStrategyError, "Invalid url pattern for GitHub Repository."
    end

    _, @owner, @repo, @filepath = *match
  end

  def download_url
    "https://#{@github_token}@github.com/#{@owner}/#{@repo}/#{@filepath}"
  end

  private

  def _fetch(url:, resolved_url:, timeout:)
    curl_download download_url, to: temporary_path
  end

  def set_github_token
    @github_token = ENV["HOMEBREW_GITHUB_API_TOKEN"]
    unless @github_token
      raise CurlDownloadStrategyError, "Environmental variable HOMEBREW_GITHUB_API_TOKEN is required."
    end

    validate_github_repository_access!
  end

  def validate_github_repository_access!
    # Test access to the repository
    GitHub.repository(@owner, @repo)
  rescue GitHub::HTTPNotFoundError
    # We only handle HTTPNotFoundError here,
    # becase AuthenticationFailedError is handled within util/github.
    message = <<~EOS
      HOMEBREW_GITHUB_API_TOKEN can not access the repository: #{@owner}/#{@repo}
      This token may not have permission to access the repository or the url of formula may be incorrect.
    EOS
    raise CurlDownloadStrategyError, message
  end
end

# GitHubPrivateRepositoryReleaseDownloadStrategy downloads tarballs from GitHub
# Release assets. To use it, add `:using => :github_private_release` to the URL section
# of your formula. This download strategy uses GitHub access tokens (in the
# environment variables HOMEBREW_GITHUB_API_TOKEN) to sign the request.
class GitHubPrivateRepositoryReleaseDownloadStrategy < GitHubPrivateRepositoryDownloadStrategy
  def initialize(url, name, version, **meta)
    super
  end

  def parse_url_pattern
    url_pattern = %r{https://github.com/([^/]+)/([^/]+)/releases/download/([^/]+)/(\S+)}
    unless @url =~ url_pattern
      raise CurlDownloadStrategyError, "Invalid url pattern for GitHub Release."
    end

    _, @owner, @repo, @tag, @filename = *@url.match(url_pattern)
  end

  def download_url
    "https://api.github.com/repos/#{@owner}/#{@repo}/releases/assets/#{asset_id}"
  end

  private

  def _fetch(url:, resolved_url:, timeout:)
    # HTTP request header `Accept: application/octet-stream` is required.
    # Without this, the GitHub API will respond with metadata, not binary.
    curl_download download_url, "--header", "Accept: application/octet-stream", "--header", "Authorization: token #{@github_token}", to: temporary_path
  end

  def asset_id
    @asset_id ||= resolve_asset_id
  end

  def resolve_asset_id
    release_metadata = fetch_release_metadata
    assets = release_metadata["assets"].select { |a| a["name"] == @filename }
    raise CurlDownloadStrategyError, "Asset file not found." if assets.empty?

    assets.first["id"]
  end

  def fetch_release_metadata
    release_url = "https://api.github.com/repos/#{@owner}/#{@repo}/releases/tags/#{@tag}"
    GitHub::API.open_rest(release_url)
  end
end

# ScpDownloadStrategy downloads files using ssh via scp. To use it, add
# `:using => :scp` to the URL section of your formula or
# provide a URL starting with scp://. This strategy uses ssh credentials for
# authentication. If a public/private keypair is configured, it will not
# prompt for a password.
#
# @example
#   class Abc < Formula
#     url "scp://example.com/src/abc.1.0.tar.gz"
#     ...
class ScpDownloadStrategy < AbstractFileDownloadStrategy
  def initialize(url, name, version, **meta)
    super
    parse_url_pattern
  end

  def parse_url_pattern
    url_pattern = %r{scp://([^@]+@)?([^@:/]+)(:\d+)?/(\S+)}
    if @url !~ url_pattern
      raise ScpDownloadStrategyError, "Invalid URL for scp: #{@url}"
    end

    _, @user, @host, @port, @path = *@url.match(url_pattern)
  end

  def fetch
    ohai "Downloading #{@url}"

    if cached_location.exist?
      puts "Already downloaded: #{cached_location}"
    else
      system_command! "scp", args: [scp_source, temporary_path.to_s]
      ignore_interrupts { temporary_path.rename(cached_location) }
    end
  end

  def clear_cache
    super
    rm_rf(temporary_path)
  end

  private

  def scp_source
    path_prefix = "/" unless @path.start_with?("~")
    port_arg = "-P #{@port[1..-1]} " if @port
    "#{port_arg}#{@user}#{@host}:#{path_prefix}#{@path}"
  end
end

class DownloadStrategyDetector
  class << self
    module Compat
      def detect(url, using = nil)
        strategy = super
        require_aws_sdk if strategy == S3DownloadStrategy
        strategy
      end

      def detect_from_url(url)
        case url
        when %r{^s3://}
          S3DownloadStrategy
        when %r{^scp://}
          ScpDownloadStrategy
        else
          super(url)
        end
      end

      def detect_from_symbol(symbol)
        case symbol
        when :github_private_repo
          GitHubPrivateRepositoryDownloadStrategy
        when :github_private_release
          GitHubPrivateRepositoryReleaseDownloadStrategy
        when :s3
          S3DownloadStrategy
        when :scp
          ScpDownloadStrategy
        else
          super(symbol)
        end
      end
    end

    prepend Compat
  end
end


테스트하지 않았지만 나중에 필요할 경우를 대비하여 파일에 남겨둔 몇 가지 추가 다운로드 전략이 있습니다. 그런 다음 Homebrew 공식에서 이 파일을 참조하고 Homebrew에 GitHubPrivateRepositoryReleaseDownloadStrategy를 사용하도록 지시할 수 있습니다.
HomebrewFormula/mytool.rb
# typed: false
# frozen_string_literal: true

# This file was generated by GoReleaser. DO NOT EDIT.
require_relative "lib/custom_download_strategy"
class Mytool < Formula
  desc ""
  homepage ""
  version "1.1.5"

  on_macos do
    if Hardware::CPU.arm?
      url "https://github.com/myorg/mytool/releases/download/v1.1.5/mytool_1.1.5_Darwin_arm64.tar.gz", :using => GitHubPrivateRepositoryReleaseDownloadStrategy
      sha256 "abc123..."

      def install
        bin.install "mytool"
      end
    end
    if Hardware::CPU.intel?
      url "https://github.com/myorg/mytool/releases/download/v1.1.5/mytool_1.1.5_Darwin_x86_64.tar.gz", :using => GitHubPrivateRepositoryReleaseDownloadStrategy
      sha256 "qwerty987..."

      def install
        bin.install "mytool"
      end
    end
  end

  on_linux do
    if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
      url "https://github.com/myorg/mytool/releases/download/v1.1.5/mytool_1.1.5_Linux_arm64.tar.gz", :using => GitHubPrivateRepositoryReleaseDownloadStrategy
      sha256 "f00bar..."

      def install
        bin.install "mytool"
      end
    end
    if Hardware::CPU.intel?
      url "https://github.com/myorg/mytool/releases/download/v1.1.5/mytool_1.1.5_Linux_x86_64.tar.gz", :using => GitHubPrivateRepositoryReleaseDownloadStrategy
      sha256 "xyz543..."

      def install
        bin.install "mytool"
      end
    end
  end
end



그런 다음 다음 명령을 실행하여 설치하기만 하면 됩니다.

HOMEBREW_GITHUB_API_TOKEN=ghp_abc123...
brew tap myorg/mytool https://github.com/myorg/mytool
brew install mytool

좋은 웹페이지 즐겨찾기