diff --git a/scripts/composer/PharInstaller.php b/scripts/composer/PharInstaller.php index 830ac536d4..6f03fa2bb1 100644 --- a/scripts/composer/PharInstaller.php +++ b/scripts/composer/PharInstaller.php @@ -2,6 +2,7 @@ namespace drunomics\Composer; +use Composer\Composer; use Composer\Script\Event; use Composer\Util\StreamContextFactory; use Symfony\Component\Filesystem\Filesystem; @@ -39,7 +40,7 @@ public static function installPharTools(Event $event) { $fs->mkdir($bin_dir); } $event->getIO()->write("Downloading $filename..."); - $content = static::download($data['url']); + $content = static::download($data['url'], $composer); $fs->dumpFile("$bin_dir/$filename", $content); $fs->chmod("$bin_dir/$filename", 0755); @@ -53,11 +54,95 @@ public static function installPharTools(Event $event) { } /** - * Downloads the URL. + * Downloads a tool binary. + * + * GitHub release-asset URLs + * (https://github.com///releases/download/...) are fetched via the + * api.github.com release-asset endpoint instead of the github.com web host. + * The web tier intermittently returns edge 504s for some CI egress IPs (and + * applies stricter unauthenticated limits), whereas the API host and its + * *.githubusercontent.com asset CDN are independent of it. Uses the + * github-oauth token Composer already holds (when present) for the API + * metadata request. Falls back to the original URL on any failure. + */ + protected static function download($url, ?Composer $composer = NULL) { + if (preg_match('#^https://github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/(.+)$#', $url, $m)) { + try { + return static::downloadGithubReleaseAsset($m[1], $m[2], $m[3], $m[4], static::githubToken($composer)); + } + catch (\RuntimeException $e) { + // Fall back to the direct github.com URL below (legacy behaviour). + } + } + return static::httpGet($url); + } + + /** + * Resolves the configured github.com OAuth token, if any. + */ + protected static function githubToken(?Composer $composer) { + if (!$composer) { + return NULL; + } + $oauth = $composer->getConfig()->get('github-oauth') ?: []; + return $oauth['github.com'] ?? NULL; + } + + /** + * Fetches a GitHub release asset via api.github.com. + * + * The binary itself is fetched without an Authorization header: the API + * 302-redirects to a pre-signed CDN URL that rejects an extra auth header, + * and these tools live in public repos. The token (when present) only + * authenticates the metadata lookup to relax unauthenticated rate limits. */ - protected static function download($url) { - $context = StreamContextFactory::getContext($url); - return file_get_contents($url, FALSE, $context); + protected static function downloadGithubReleaseAsset($owner, $repo, $tag, $name, $token) { + $headers = ['Accept: application/vnd.github+json']; + if ($token) { + $headers[] = 'Authorization: token ' . $token; + } + $meta = static::httpGet("https://api.github.com/repos/$owner/$repo/releases/tags/" . rawurlencode($tag), $headers); + $release = json_decode($meta, TRUE); + if (!is_array($release) || empty($release['assets'])) { + throw new \RuntimeException("No release assets for $owner/$repo@$tag."); + } + foreach ($release['assets'] as $asset) { + if (isset($asset['name'], $asset['url']) && $asset['name'] === $name) { + return static::httpGet($asset['url'], ['Accept: application/octet-stream']); + } + } + throw new \RuntimeException("Release asset '$name' not found in $owner/$repo@$tag."); + } + + /** + * HTTP GET via Composer's stream context (proxy/TLS aware), with retries. + */ + protected static function httpGet($url, array $headers = []) { + $headers[] = 'User-Agent: drunomics phar-installer'; + $options = [ + 'http' => [ + 'header' => $headers, + 'follow_location' => 1, + 'max_redirects' => 5, + 'timeout' => 60, + ], + ]; + $last = 'no response'; + for ($attempt = 1; $attempt <= 3; $attempt++) { + $context = StreamContextFactory::getContext($url, $options); + $content = @file_get_contents($url, FALSE, $context); + if ($content !== FALSE && $content !== '') { + return $content; + } + if (isset($http_response_header[0])) { + $last = $http_response_header[0]; + } + if ($attempt < 3) { + // Brief backoff for transient 5xx / network blips. + sleep($attempt * 2); + } + } + throw new \RuntimeException("Failed to download $url ($last)."); } }