Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 51 additions & 14 deletions Library/Homebrew/github_packages.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# typed: strict
# frozen_string_literal: true

require "utils/curl"
Expand All @@ -16,8 +16,8 @@ class GitHubPackages
include Utils::Output::Mixin

URL_DOMAIN = "ghcr.io"
URL_PREFIX = "https://#{URL_DOMAIN}/v2/".freeze
DOCKER_PREFIX = "docker://#{URL_DOMAIN}/".freeze
URL_PREFIX = T.let("https://#{URL_DOMAIN}/v2/".freeze, String)
DOCKER_PREFIX = T.let("docker://#{URL_DOMAIN}/".freeze, String)
public_constant :URL_DOMAIN
private_constant :URL_PREFIX
private_constant :DOCKER_PREFIX
Expand All @@ -30,16 +30,22 @@ class GitHubPackages
INVALID_OCI_TAG_CHARS_REGEX = /[^a-zA-Z0-9._-]/

# Translate Homebrew tab.arch to OCI platform.architecture
TAB_ARCH_TO_PLATFORM_ARCHITECTURE = {
"arm64" => "arm64",
"x86_64" => "amd64",
}.freeze
TAB_ARCH_TO_PLATFORM_ARCHITECTURE = T.let(
{
"arm64" => "arm64",
"x86_64" => "amd64",
}.freeze,
T::Hash[String, String],
)

# Translate Homebrew built_on.os to OCI platform.os
BUILT_ON_OS_TO_PLATFORM_OS = {
"Linux" => "linux",
"Macintosh" => "darwin",
}.freeze
BUILT_ON_OS_TO_PLATFORM_OS = T.let(
{
"Linux" => "linux",
"Macintosh" => "darwin",
}.freeze,
T::Hash[String, String],
)

sig {
params(
Expand Down Expand Up @@ -79,6 +85,7 @@ def upload_bottles(bottles_hash, keep_old:, dry_run:, warn_on_error:)
# rubocop:enable Style/CombinableLoops
end

sig { params(version: Version, rebuild: Integer, bottle_tag: T.nilable(String)).returns(String) }
def self.version_rebuild(version, rebuild, bottle_tag = nil)
bottle_tag = (".#{bottle_tag}" if bottle_tag.present?)

Expand All @@ -93,18 +100,21 @@ def self.version_rebuild(version, rebuild, bottle_tag = nil)
"#{version}#{bottle_tag}#{rebuild}"
end

sig { params(repo: String).returns(String) }
def self.repo_without_prefix(repo)
# Remove redundant repository prefix for a shorter name.
repo.delete_prefix("homebrew-")
end

sig { params(org: String, repo: String, prefix: String).returns(String) }
def self.root_url(org, repo, prefix = URL_PREFIX)
# `docker`/`skopeo` insist on lowercase organisation (“repository name”).
org = org.downcase

"#{prefix}#{org}/#{repo_without_prefix(repo)}"
end

sig { params(url: T.nilable(String)).returns(T.nilable(String)) }
def self.root_url_if_match(url)
return if url.blank?

Expand All @@ -114,6 +124,7 @@ def self.root_url_if_match(url)
root_url(org, repo)
end

sig { params(formula_name: String).returns(String) }
def self.image_formula_name(formula_name)
# Invalid docker name characters:
# - `/` makes sense because we already use it to separate repository/formula.
Expand All @@ -122,6 +133,7 @@ def self.image_formula_name(formula_name)
.tr("+", "x")
end

sig { params(version_rebuild: String).returns(String) }
def self.image_version_rebuild(version_rebuild)
unless version_rebuild.match?(VALID_OCI_TAG_REGEX)
raise ArgumentError, "GitHub Packages versions must match #{VALID_OCI_TAG_REGEX.source}!"
Expand All @@ -141,6 +153,7 @@ def self.image_version_rebuild(version_rebuild)
private_constant :IMAGE_CONFIG_SCHEMA_URI, :IMAGE_INDEX_SCHEMA_URI, :IMAGE_LAYOUT_SCHEMA_URI,
:IMAGE_MANIFEST_SCHEMA_URI, :GITHUB_PACKAGE_TYPE

sig { void }
def load_schemas!
schema_uri("content-descriptor",
"https://opencontainers.org/schema/image/content-descriptor.json")
Expand Down Expand Up @@ -168,6 +181,7 @@ def load_schemas!
schema_uri("image-manifest-schema", IMAGE_MANIFEST_SCHEMA_URI)
end

sig { params(basename: String, uris: T.any(String, T::Array[String])).void }
def schema_uri(basename, uris)
# The current `main` version has an invalid JSON schema.
# Going forward, this should probably be pinned to tags.
Expand All @@ -176,18 +190,20 @@ def schema_uri(basename, uris)
out = Utils::Curl.curl_output(url).stdout
json = JSON.parse(out)

@schema_json ||= {}
@schema_json ||= T.let({}, T.nilable(T::Hash[String, T::Hash[String, T.untyped]]))
Array(uris).each do |uri|
@schema_json[uri] = json
end
end

sig { params(uri: URI::Generic).returns(T.nilable(T::Hash[String, T.untyped])) }
def schema_resolver(uri)
@schema_json[uri.to_s.gsub(/#.*/, "")]
@schema_json&.fetch(uri.to_s.gsub(/#.*/, ""))
end

sig { params(schema_uri: String, json: T::Hash[String, T.untyped]).void }
def validate_schema!(schema_uri, json)
schema = JSONSchemer.schema(@schema_json[schema_uri], ref_resolver: method(:schema_resolver))
schema = JSONSchemer.schema(@schema_json&.fetch(schema_uri), ref_resolver: method(:schema_resolver))
json = json.deep_stringify_keys
return if schema.valid?(json)

Expand All @@ -200,6 +216,7 @@ def validate_schema!(schema_uri, json)
exit 1
end

sig { params(user: String, token: String, skopeo: Pathname, image_uri: String, root: Pathname, dry_run: T::Boolean).void }
def download(user, token, skopeo, image_uri, root, dry_run:)
puts
args = ["copy", "--all", image_uri.to_s, "oci:#{root}"]
Expand All @@ -211,6 +228,14 @@ def download(user, token, skopeo, image_uri, root, dry_run:)
end
end

sig {
params(
user: String, token: String, skopeo: Pathname, _formula_full_name: String,
bottle_hash: T::Hash[String, T.untyped], keep_old: T::Boolean, dry_run: T::Boolean, warn_on_error: T::Boolean
).returns(
T.nilable([String, String, String, Version, Integer, String, String, String, T::Boolean]),
)
}
def preupload_check(user, token, skopeo, _formula_full_name, bottle_hash, keep_old:, dry_run:, warn_on_error:)
formula_name = bottle_hash["formula"]["name"]

Expand Down Expand Up @@ -260,6 +285,12 @@ def preupload_check(user, token, skopeo, _formula_full_name, bottle_hash, keep_o
[formula_name, org, repo, version, rebuild, version_rebuild, image_name, image_uri, keep_old]
end

sig {
params(
user: String, token: String, skopeo: Pathname, formula_full_name: String,
bottle_hash: T::Hash[String, T.untyped], keep_old: T::Boolean, dry_run: T::Boolean, warn_on_error: T::Boolean
).void
}
def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:, dry_run:, warn_on_error:)
# We run the preupload check twice to prevent TOCTOU bugs.
result = preupload_check(user, token, skopeo, formula_full_name, bottle_hash,
Expand Down Expand Up @@ -479,19 +510,22 @@ def upload_bottle(user, token, skopeo, formula_full_name, bottle_hash, keep_old:
end
end

sig { params(root: Pathname).returns([String, Integer]) }
def write_image_layout(root)
image_layout = { imageLayoutVersion: "1.0.0" }
validate_schema!(IMAGE_LAYOUT_SCHEMA_URI, image_layout)
write_hash(root, image_layout, "oci-layout")
end

sig { params(local_file: String, blobs: Pathname).returns(String) }
def write_tar_gz(local_file, blobs)
tar_gz_sha256 = Digest::SHA256.file(local_file)
.hexdigest
FileUtils.ln local_file, blobs/tar_gz_sha256, force: true
tar_gz_sha256
end

sig { params(platform_hash: T::Hash[String, T.untyped], tar_sha256: String, blobs: Pathname).returns([String, Integer]) }
def write_image_config(platform_hash, tar_sha256, blobs)
image_config = platform_hash.merge({
rootfs: {
Expand All @@ -503,6 +537,7 @@ def write_image_config(platform_hash, tar_sha256, blobs)
write_hash(blobs, image_config)
end

sig { params(manifests: T::Array[T::Hash[String, T.untyped]], blobs: Pathname, annotations: T::Hash[String, String]).returns([String, Integer]) }
def write_image_index(manifests, blobs, annotations)
image_index = {
schemaVersion: 2,
Expand All @@ -513,6 +548,7 @@ def write_image_index(manifests, blobs, annotations)
write_hash(blobs, image_index)
end

sig { params(index_json_sha256: String, index_json_size: Integer, root: Pathname, annotations: T::Hash[String, String]).void }
def write_index_json(index_json_sha256, index_json_size, root, annotations)
index_json = {
schemaVersion: 2,
Expand All @@ -527,6 +563,7 @@ def write_index_json(index_json_sha256, index_json_size, root, annotations)
write_hash(root, index_json, "index.json")
end

sig { params(directory: Pathname, hash: T::Hash[String, T.untyped], filename: T.nilable(String)).returns([String, Integer]) }
def write_hash(directory, hash, filename = nil)
json = JSON.pretty_generate(hash)
sha256 = Digest::SHA256.hexdigest(json)
Expand Down
Loading