brew/Library/Homebrew/github_packages.rb
Mike McQuaid 74f31af8d4
github_packages: use skopeo.
`skopeo` allows replacing `docker` and `oras` with a single tool that
behaves in a way that's a little more intuitive for us (we build the
directory structure on disk and upload that). It seems to be better at
preserving our metadata, too.

Take the opportunity for writing out more metadata ourselves to split
out more logic into separate functions.

While we're here, fix the `download_strategy` timeout handling for
GitHub Packages.
2021-03-19 21:09:18 +00:00

237 lines
7.4 KiB
Ruby

# typed: false
# frozen_string_literal: true
require "utils/curl"
require "json"
# GitHub Packages client.
#
# @api private
class GitHubPackages
extend T::Sig
include Context
URL_DOMAIN = "ghcr.io"
URL_PREFIX = "https://#{URL_DOMAIN}/v2/"
URL_REGEX = %r{#{Regexp.escape(URL_PREFIX)}([\w-]+)/([\w-]+)}.freeze
sig { returns(String) }
def inspect
"#<GitHubPackages: org=#{@github_org}>"
end
sig { params(org: T.nilable(String)).void }
def initialize(org: "homebrew")
@github_org = org
raise UsageError, "Must set a GitHub organisation!" unless @github_org
ENV["HOMEBREW_FORCE_HOMEBREW_ON_LINUX"] = "1" if @github_org == "homebrew" && !OS.mac?
end
sig { params(bottles_hash: T::Hash[String, T.untyped]).void }
def upload_bottles(bottles_hash)
user = Homebrew::EnvConfig.github_packages_user
token = Homebrew::EnvConfig.github_packages_token
raise UsageError, "HOMEBREW_GITHUB_PACKAGES_USER is unset." if user.blank?
raise UsageError, "HOMEBREW_GITHUB_PACKAGES_TOKEN is unset." if token.blank?
skopeo = HOMEBREW_PREFIX/"bin/skopeo"
unless skopeo.exist?
ohai "Installing `skopeo` for upload..."
safe_system HOMEBREW_BREW_FILE, "install", "--formula", "skopeo"
skopeo = Formula["skopeo"].opt_bin/"skopeo"
end
bottles_hash.each do |formula_name, bottle_hash|
upload_bottle(user, token, skopeo, formula_name, bottle_hash)
end
end
private
def upload_bottle(user, token, skopeo, formula_name, bottle_hash)
_, org, repo, = *bottle_hash["bottle"]["root_url"].match(URL_REGEX)
# docker/skopeo insist on lowercase org ("repository name")
org = org.downcase
version = bottle_hash["formula"]["pkg_version"]
rebuild = if (rebuild = bottle_hash["bottle"]["rebuild"]).positive?
".#{rebuild}"
end
version_rebuild = "#{version}#{rebuild}"
root = Pathname("#{formula_name}-#{version_rebuild}")
write_oci_layout(root)
blobs = root/"blobs/sha256"
blobs.mkpath
formula_path = HOMEBREW_REPOSITORY/bottle_hash["formula"]["path"]
formula = Formulary.factory(formula_path)
# TODO: ideally most/all of these attributes would be stored in the
# bottle JSON rather than reading them from the formula.
git_revision = formula.tap.git_head
git_path = formula_path.to_s.delete_prefix("#{formula.tap.path}/")
source = "https://github.com/#{org}/#{repo}/blob/#{git_revision}/#{git_path}"
formula_annotations_hash = {
"org.opencontainers.image.description" => formula.desc,
"org.opencontainers.image.license" => formula.license,
"org.opencontainers.image.revision" => git_revision,
"org.opencontainers.image.source" => source,
"org.opencontainers.image.url" => formula.homepage,
"org.opencontainers.image.vendor" => org,
"org.opencontainers.image.version" => version,
}
manifests = bottle_hash["bottle"]["tags"].map do |bottle_tag, tag_hash|
local_file = tag_hash["local_filename"]
odebug "Uploading #{local_file}"
tar_gz_sha256 = write_tar_gz(local_file, blobs)
tab = Tab.from_file_content(
Utils.safe_popen_read("tar", "xfO", local_file, "#{formula_name}/#{version}/INSTALL_RECEIPT.json"),
"#{local_file}/#{formula_name}/#{version}",
)
os_version = if tab.built_on.present?
/(\d+\.)*\d+/ =~ tab.built_on["os_version"]
Regexp.last_match(0)
end
# TODO: ideally most/all of these attributes would be stored in the
# bottle JSON rather than reading them from the formula.
os, arch, formulae_dir = if @bottle_tag.to_s.end_with?("_linux")
["linux", "amd64", "formula-linux"]
else
os = "darwin"
macos_version = MacOS::Version.from_symbol(bottle_tag.to_sym)
os_version ||= macos_version.to_f.to_s
arch = if macos_version.arch == :arm64
"arm64"
else
"amd64"
end
[os, arch, "formula"]
end
platform_hash = {
architecture: arch,
os: os,
"os.version" => os_version,
}
tar_sha256 = Digest::SHA256.hexdigest(
Utils.safe_popen_read("gunzip", "--stdout", "--decompress", local_file),
)
config_json_sha256, config_json_size = write_config(platform_hash, tar_sha256, blobs)
created_time = tab.source_modified_time
created_time ||= Time.now
documentation = "https://formulae.brew.sh/#{formulae_dir}/#{formula_name}" if formula.tap.core_tap?
tag = "#{version}.#{bottle_tag}#{rebuild}"
title = "#{formula.full_name} #{tag}"
annotations_hash = formula_annotations_hash.merge({
"org.opencontainers.image.created" => created_time.strftime("%F"),
"org.opencontainers.image.documentation" => documentation,
"org.opencontainers.image.ref.name" => tag,
"org.opencontainers.image.title" => title,
}).sort.to_h
annotations_hash.each do |key, value|
annotations_hash.delete(key) if value.blank?
end
manifest_json_sha256, manifest_json_size = write_hash(blobs, {
schemaVersion: 2,
config: {
mediaType: "application/vnd.oci.image.config.v1+json",
digest: "sha256:#{config_json_sha256}",
size: config_json_size,
},
layers: [{
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
digest: "sha256:#{tar_gz_sha256}",
size: File.size(local_file),
annotations: {
"org.opencontainers.image.title": local_file,
},
}],
annotations: annotations_hash,
})
{
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: "sha256:#{manifest_json_sha256}",
size: manifest_json_size,
platform: platform_hash,
}
end
index_json_sha256, index_json_size = write_index(manifests, blobs)
write_index_json(index_json_sha256, index_json_size, root)
image = "#{URL_DOMAIN}/#{org}/#{repo}/#{formula_name}"
image_tag = "#{image}:#{version_rebuild}"
puts
system_command!(skopeo, verbose: true, print_stdout: true, args: [
"copy", "--dest-creds=#{user}:#{token}",
"oci:#{root}", "docker://#{image_tag}"
])
end
def write_oci_layout(root)
write_hash(root, { imageLayoutVersion: "1.0.0" }, "oci-layout")
end
def write_tar_gz(local_file, blobs)
tar_gz_sha256 = Digest::SHA256.file(local_file)
.hexdigest
FileUtils.cp local_file, blobs/tar_gz_sha256
tar_gz_sha256
end
def write_config(platform_hash, tar_sha256, blobs)
write_hash(blobs, platform_hash.merge({
rootfs: {
type: "layers",
diff_ids: ["sha256:#{tar_sha256}"],
},
}))
end
def write_index(manifests, blobs)
write_hash(blobs, {
schemaVersion: 2,
manifests: manifests,
})
end
def write_index_json(index_json_sha256, index_json_size, root)
write_hash(root, {
schemaVersion: 2,
manifests: [{
mediaType: "application/vnd.oci.image.index.v1+json",
digest: "sha256:#{index_json_sha256}",
size: index_json_size,
}],
}, "index.json")
end
def write_hash(directory, hash, _filename = nil)
json = hash.to_json
sha256 = Digest::SHA256.hexdigest(json)
path = directory/sha256
path.unlink if path.exist?
path.write(json)
[sha256, json.size]
end
end