diff --git a/README.md b/README.md
index 07929d5..5c733ca 100644
--- a/README.md
+++ b/README.md
@@ -463,6 +463,14 @@ This ensures consistent Velox versions across different environments and team me
+
+
+
+
+
+
```
@@ -553,16 +561,52 @@ Each developer gets the correct binaries for their system:
```
-## GitHub API Rate Limits
+## API Rate Limits
Use a personal access token to avoid rate limits:
```bash
GITHUB_TOKEN=your_token_here ./vendor/bin/dload get
+GITLAB_TOKEN=your_token_here ./vendor/bin/dload get
```
Add to CI/CD environment variables for automated downloads.
+## Gitlab CI configuration
+
+When you make a release in Gitlab, make sure to upload your assets to the release page via
+package manager. This can easily be done via Gitlab CLI and the `glab release upload --use-package-registry`
+command.
+
+```
+# .gitlab-ci.yml
+
+Build artifacts:
+ stage: push
+ script:
+ - mkdir bin
+ - echo "Mock binary for darwin arm" > bin/cool-darwin-arm64
+ - echo "Mock binary for darwin amd" > bin/cool-darwin-amd64
+ - echo "Mock binary for linux arm" > bin/cool-linux-arm64
+ - echo "Mock binary for linux amd" > bin/cool-linux-amd64
+ artifacts:
+ expire_in: 2 hours
+ paths:
+ - $CI_PROJECT_DIR/bin/cool-*
+ rules:
+ - if: $CI_COMMIT_TAG
+
+Release artifacts:
+ stage: deploy
+ image: gitlab/glab:latest
+ needs: [ "Build artifacts" ]
+ script:
+ - glab auth login --job-token $CI_JOB_TOKEN --hostname $CI_SERVER_HOST
+ - glab release upload --use-package-registry "$CI_COMMIT_TAG" ./bin/*
+ rules:
+ - if: $CI_COMMIT_TAG
+```
+
## Contributing
Contributions welcome! Submit Pull Requests to:
diff --git a/src/Bootstrap.php b/src/Bootstrap.php
index a85759c..a4bc5ff 100644
--- a/src/Bootstrap.php
+++ b/src/Bootstrap.php
@@ -14,6 +14,7 @@
use Internal\DLoad\Module\HttpClient\Factory;
use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl;
use Internal\DLoad\Module\Repository\Internal\GitHub\Factory as GithubRepositoryFactory;
+use Internal\DLoad\Module\Repository\Internal\GitLab\Factory as GitLabRepositoryFactory;
use Internal\DLoad\Module\Repository\RepositoryProvider;
use Internal\DLoad\Module\Velox\ApiClient;
use Internal\DLoad\Module\Velox\Builder;
@@ -106,7 +107,8 @@ public function withConfig(
$this->container->bind(
RepositoryProvider::class,
static fn(Container $container): RepositoryProvider => (new RepositoryProvider())
- ->addRepositoryFactory($container->get(GithubRepositoryFactory::class)),
+ ->addRepositoryFactory($container->get(GithubRepositoryFactory::class))
+ ->addRepositoryFactory($container->get(GitLabRepositoryFactory::class)),
);
$this->container->bind(BinaryProvider::class, BinaryProviderImpl::class);
$this->container->bind(Factory::class, NyholmFactoryImpl::class);
diff --git a/src/Module/Config/Schema/GitLab.php b/src/Module/Config/Schema/GitLab.php
new file mode 100644
index 0000000..e049139
--- /dev/null
+++ b/src/Module/Config/Schema/GitLab.php
@@ -0,0 +1,21 @@
+
+ */
+ private array $defaultHeaders = [
+ 'accept' => 'application/json',
+ ];
+
+ public function __construct(
+ private readonly HttpFactory $httpFactory,
+ private readonly ClientInterface $client,
+ private readonly GitLab $gitLabConfig,
+ ) {
+ // Add authorization header if token is available
+ $this->gitLabConfig->token !== null and $this->defaultHeaders['authorization'] = 'Bearer ' . $this->gitLabConfig->token;
+ }
+
+ /**
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function downloadArtifact(string|UriInterface $uri): ResponseInterface
+ {
+ $headers = [];
+ if ($this->gitLabConfig->token !== null) {
+ $headers = [
+ 'PRIVATE-TOKEN' => $this->gitLabConfig->token,
+ ];
+ }
+
+ $request = $this->httpFactory->request(Method::Get, $uri, $headers);
+
+ return $this->sendRequest($request);
+ }
+
+ /**
+ * @param Method|non-empty-string $method
+ * @param array $headers
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface
+ {
+ $request = $this->httpFactory->request($method, $uri, $headers + $this->defaultHeaders);
+
+ return $this->sendRequest($request);
+ }
+
+ /**
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $response = $this->client->sendRequest($request);
+
+ if ($response->getStatusCode() === 429) {
+ throw new GitLabRateLimitException();
+ }
+
+ return $response;
+ }
+}
diff --git a/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php
new file mode 100644
index 0000000..020aeb1
--- /dev/null
+++ b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php
@@ -0,0 +1,179 @@
+repositoryPath = $projectPath;
+ }
+
+ /**
+ * @param non-empty-string $repositoryPath
+ * @param non-empty-string $releaseName
+ * @param non-empty-string $fileName
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function downloadArtifact(string $repositoryPath, string $releaseName, string $fileName): ResponseInterface
+ {
+ $url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), $releaseName, $fileName);
+ return $this->client->downloadArtifact($url);
+ }
+
+ /**
+ * @param Method|non-empty-string $method
+ * @param array $headers
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface
+ {
+ return $this->client->request($method, $uri, $headers);
+ }
+
+ /**
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function getRepository(): RepositoryInfo
+ {
+ $response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, \urlencode($this->repositoryPath)));
+
+ /** @var array{
+ * name: string,
+ * name_with_namespace: string,
+ * description: string|null,
+ * web_url: string,
+ * visibility: bool,
+ * created_at: string,
+ * updated_at: string
+ * } $data */
+ $data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
+
+ return RepositoryInfo::fromApiResponse($data);
+ }
+
+ /**
+ * @param int<1, max> $page
+ * @return Paginator
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ public function getReleases(int $page = 1): Paginator
+ {
+ $pageLoader = function () use ($page): \Generator {
+ $currentPage = $page;
+
+ do {
+ try {
+ $response = $this->releasesRequest($currentPage);
+
+ /** @var array
+ * },
+ * upcoming_release: bool
+ * }> $data */
+ $data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
+
+ // If empty response, no more pages
+ if ($data === []) {
+ return;
+ }
+
+ $releases = [];
+ foreach ($data as $releaseData) {
+ try {
+ $releases[] = ReleaseInfo::fromApiResponse($releaseData);
+ } catch (\Throwable) {
+ // Skip invalid releases
+ continue;
+ }
+ }
+
+ yield $releases;
+
+ // Check if there are more pages
+ $hasMorePages = $this->hasNextPage($response);
+ $currentPage++;
+ } catch (ClientExceptionInterface) {
+ return;
+ }
+ } while ($hasMorePages);
+ };
+
+ return Paginator::createFromGenerator($pageLoader(), null);
+ }
+
+ /**
+ * @param positive-int $page
+ * @throws GitLabRateLimitException
+ * @throws ClientExceptionInterface
+ */
+ private function releasesRequest(int $page): ResponseInterface
+ {
+ return $this->request(
+ Method::Get,
+ $this->httpFactory->uri(
+ \sprintf(self::URL_RELEASES, \urlencode($this->repositoryPath)),
+ ['page' => $page],
+ ),
+ );
+ }
+
+ private function hasNextPage(ResponseInterface $response): bool
+ {
+ $headers = $response->getHeaders();
+ $link = $headers['link'] ?? [];
+
+ if (!isset($link[0])) {
+ return false;
+ }
+
+ return \str_contains($link[0], 'rel="next"');
+ }
+}
diff --git a/src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php b/src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php
new file mode 100644
index 0000000..65544eb
--- /dev/null
+++ b/src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php
@@ -0,0 +1,42 @@
+ $assets
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly string $tagName,
+ public readonly \DateTimeImmutable $publishedAt,
+ public readonly array $assets,
+ public readonly bool $prerelease,
+ ) {}
+
+ /**
+ * @param array{
+ * name: non-empty-string|null,
+ * tag_name: non-empty-string,
+ * description: null|non-empty-string,
+ * created_at: non-empty-string,
+ * released_at: non-empty-string,
+ * assets: array{
+ * links: list
+ * },
+ * upcoming_release: bool
+ * } $data
+ */
+ public static function fromApiResponse(array $data): self
+ {
+ $assets = [];
+ foreach ($data['assets']['links'] as $assetData) {
+ $assets[] = AssetInfo::fromApiResponse($assetData);
+ }
+
+ return new self(
+ name: $data['name'] ?? $data['tag_name'],
+ tagName: $data['tag_name'],
+ publishedAt: new \DateTimeImmutable($data['released_at']),
+ assets: $assets,
+ prerelease: $data['upcoming_release'],
+ );
+ }
+}
diff --git a/src/Module/Repository/Internal/GitLab/Api/Response/RepositoryInfo.php b/src/Module/Repository/Internal/GitLab/Api/Response/RepositoryInfo.php
new file mode 100644
index 0000000..a0b2f32
--- /dev/null
+++ b/src/Module/Repository/Internal/GitLab/Api/Response/RepositoryInfo.php
@@ -0,0 +1,53 @@
+gitLabClient = new Client(
+ $httpFactory,
+ $httpFactory->client(),
+ $gitLabConfig,
+ );
+ }
+
+ public function supports(RepositoryConfig $config): bool
+ {
+ return \strtolower($config->type) === 'gitlab';
+ }
+
+ public function create(RepositoryConfig $config): GitLabRepository
+ {
+ $uri = \parse_url($config->uri, PHP_URL_PATH) ?? $config->uri;
+ $api = $this->createRepositoryApi($uri);
+
+ return new GitLabRepository($api, $uri);
+ }
+
+ /**
+ * @param non-empty-string $projectPath
+ */
+ private function createRepositoryApi(string $projectPath): RepositoryApi
+ {
+ return new RepositoryApi($this->gitLabClient, $this->httpFactory, $projectPath);
+ }
+}
diff --git a/src/Module/Repository/Internal/GitLab/GitLabAsset.php b/src/Module/Repository/Internal/GitLab/GitLabAsset.php
new file mode 100644
index 0000000..b4c9383
--- /dev/null
+++ b/src/Module/Repository/Internal/GitLab/GitLabAsset.php
@@ -0,0 +1,79 @@
+name, $dto->downloadUrl);
+ }
+
+ /**
+ * @param null|\Closure(int $dlNow, int|null $dlSize, array $info): mixed $progress
+ * throwing any exceptions MUST abort the request;
+ * it MUST be called on DNS resolution, on arrival of headers and on completion;
+ * it SHOULD be called on upload/download of data and at least 1/s
+ *
+ * @return \Generator
+ * @throws ClientExceptionInterface
+ */
+ public function download(?\Closure $progress = null): \Generator
+ {
+ $response = $this->api->downloadArtifact($this->release->getRepository()->getName(), $this->release->getName(), $this->getName());
+
+ $body = $response->getBody();
+ $size = $body->getSize();
+ $loaded = 0;
+
+ while (!$body->eof()) {
+ $chunk = $body->read(8192);
+ $loaded += \strlen($chunk);
+ $progress === null or $progress($loaded, $size, []);
+ yield $chunk;
+ }
+ }
+
+ public function destroy(): void
+ {
+ unset($this->release);
+ }
+}
diff --git a/src/Module/Repository/Internal/GitLab/GitLabRelease.php b/src/Module/Repository/Internal/GitLab/GitLabRelease.php
new file mode 100644
index 0000000..d3f655b
--- /dev/null
+++ b/src/Module/Repository/Internal/GitLab/GitLabRelease.php
@@ -0,0 +1,58 @@
+tagName);
+ $result = new self($repository, $dto->name, $version);
+
+ $result->assets = AssetsCollection::create(static function () use ($api, $result, $dto): \Generator {
+ foreach ($dto->assets as $assetDTO) {
+ yield GitLabAsset::fromDTO($api, $result, $assetDTO);
+ }
+ });
+
+ return $result;
+ }
+
+ public function destroy(): void
+ {
+ $this->assets === null or $this->assets->map(
+ static fn(object $asset) => $asset instanceof Destroyable and $asset->destroy(),
+ );
+
+ unset($this->assets, $this->repository);
+ }
+}
diff --git a/src/Module/Repository/Internal/GitLab/GitLabRepository.php b/src/Module/Repository/Internal/GitLab/GitLabRepository.php
new file mode 100644
index 0000000..8e1c9c4
--- /dev/null
+++ b/src/Module/Repository/Internal/GitLab/GitLabRepository.php
@@ -0,0 +1,105 @@
+name = $projectPath;
+ }
+
+ /**
+ * Returns a lazily loaded collection of repository releases.
+ * Pages are loaded only when needed during iteration or filtering.
+ */
+ public function getReleases(): ReleasesCollection
+ {
+ if ($this->releases !== null) {
+ return $this->releases;
+ }
+
+ // Create a generator function for lazy loading release pages
+ $pageLoader = function (): \Generator {
+ $page = 0;
+
+ do {
+ try {
+ // to avoid first eager loading because of generator
+ yield [];
+
+ $paginator = $this->api->getReleases(++$page);
+ $releases = $paginator->getPageItems();
+
+ $toYield = [];
+ foreach ($releases as $releaseDTO) {
+ try {
+ $toYield[] = GitLabRelease::fromDTO($this->api, $this, $releaseDTO);
+ } catch (\Throwable) {
+ // Skip invalid releases
+ continue;
+ }
+ }
+ yield $toYield;
+
+ // Check if there are more pages by getting next page
+ $hasMorePages = $paginator->getNextPage() !== null;
+ } catch (GitLabRateLimitException $e) {
+ throw $e;
+ } catch (\Throwable) {
+ return;
+ }
+ } while ($hasMorePages);
+ };
+
+ // Create paginator
+ $paginator = \Internal\DLoad\Module\Repository\Internal\Paginator::createFromGenerator($pageLoader(), null);
+
+ // Create a collection with the paginator
+ $this->releases = ReleasesCollection::create($paginator);
+
+ return $this->releases;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function destroy(): void
+ {
+ $this->releases === null or $this->releases->map(
+ static fn(object $release) => $release instanceof Destroyable and $release->destroy(),
+ );
+
+ unset($this->releases);
+ }
+}