From 366f1b95d4cb07f6a06b927e094f7847d73ee2cd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 16:22:13 +0400 Subject: [PATCH 1/3] feat(registry): select releases by a tag prefix A repository can declare `tag-prefix` (e.g. `bun-`): releases whose tag does not start with it are ignored, and the prefix is cut off before the version is parsed. An explicit prefix instead of a lenient version parser keeps a release-please monorepo from mixing components, where `cli-v2.0.0` and `sdk-v1.4.0` would otherwise be taken as versions of one tool. Releases now expose the original tag through `getTag()`, so forgetting a deleted release in the version registry no longer reconstructs the tag from the version. Assisted-By: Claude Opus 5.5 --- README-es.md | 7 ++++ README-ru.md | 7 ++++ README-zh.md | 7 ++++ README.md | 7 ++++ dload.xsd | 5 +++ resources/software.schema.json | 4 +++ .../references/registry-entry.md | 8 ++++- .../references/troubleshooting.md | 1 + src/Module/Config/Schema/Embed/Repository.php | 12 ++++++- src/Module/Downloader/Downloader.php | 3 +- .../Repository/Internal/GitHub/Factory.php | 2 +- .../Internal/GitHub/GitHubRelease.php | 12 +++++-- .../Internal/GitHub/GitHubRepository.php | 8 ++++- .../Repository/Internal/GitLab/Factory.php | 2 +- .../Internal/GitLab/GitLabRelease.php | 12 +++++-- .../Internal/GitLab/GitLabRepository.php | 8 ++++- src/Module/Repository/Internal/Release.php | 7 ++++ src/Module/Repository/ReleaseInterface.php | 18 +++++++--- .../Config/Schema/Embed/RepositoryTest.php | 30 ++++++++++++++++ .../Unit/Module/Downloader/DownloaderTest.php | 14 ++++++++ .../Internal/GitHub/GitHubRepositoryTest.php | 35 ++++++++++++++++++- .../Internal/GitLab/GitLabRepositoryTest.php | 35 ++++++++++++++++++- .../Module/Repository/Stub/ReleaseStub.php | 7 ++++ 23 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/Module/Config/Schema/Embed/RepositoryTest.php diff --git a/README-es.md b/README-es.md index b3ca0532..f9963562 100644 --- a/README-es.md +++ b/README-es.md @@ -560,6 +560,12 @@ El binario de RoadRunner construido incluirá solo los plugins especificados en + + + + + + ``` @@ -571,6 +577,7 @@ El binario de RoadRunner construido incluirá solo los plugins especificados en - **type**: Actualmente soporta "github" - **uri**: Ruta del repositorio (ej., "username/repo") - **asset-pattern**: Patrón regex para hacer match con assets de release +- **tag-prefix**: Texto antes de la versión en los tags de release, p. ej. `bun-` para `bun-v1.4.2`. Los releases con otros tags se ignoran, así que de un monorepo con tags por componente (release-please) solo se usa el componente seleccionado #### Elementos Binary diff --git a/README-ru.md b/README-ru.md index 9c34f9e5..38d78516 100644 --- a/README-ru.md +++ b/README-ru.md @@ -562,6 +562,12 @@ DLoad автоматически управляет процессом сбор + + + + + + ``` @@ -573,6 +579,7 @@ DLoad автоматически управляет процессом сбор - **type**: В настоящее время поддерживает "github" - **uri**: Путь репозитория (например, "username/repo") - **asset-pattern**: Regex-паттерн для соответствия ресурсам релиза +- **tag-prefix**: Текст перед версией в тегах релизов, например `bun-` для `bun-v1.4.2`. Релизы с другими тегами игнорируются, поэтому из монорепозитория с тегами по компонентам (release-please) берётся только нужный компонент #### Элементы Binary diff --git a/README-zh.md b/README-zh.md index 463b9b13..2316910f 100644 --- a/README-zh.md +++ b/README-zh.md @@ -553,6 +553,12 @@ DLoad 会自动处理构建过程: + + + + + + ``` @@ -564,6 +570,7 @@ DLoad 会自动处理构建过程: - **type**:目前支持 "github" - **uri**:仓库路径(例如 "username/repo") - **asset-pattern**:匹配发布资源的正则表达式模式 +- **tag-prefix**:发布标签中版本号之前的文本,例如 `bun-v1.4.2` 中的 `bun-`。标签不以它开头的发布会被忽略,因此对于按组件打标签的 monorepo(release-please),只会使用选定的组件 #### 二进制要素 diff --git a/README.md b/README.md index 86bfe516..cc98273c 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,12 @@ This ensures consistent Velox versions across different environments and team me + + + + + + ``` @@ -577,6 +583,7 @@ This ensures consistent Velox versions across different environments and team me - **type**: Currently supports "github" - **uri**: Repository path (e.g., "username/repo") - **asset-pattern**: Regex pattern to match release assets +- **tag-prefix**: Text before the version in release tags, e.g. `bun-` for `bun-v1.4.2`. Releases with other tags are ignored, so only the selected component of a monorepo with per-component tags (release-please) is used #### Binary Elements diff --git a/dload.xsd b/dload.xsd index b737373b..3fa0168a 100644 --- a/dload.xsd +++ b/dload.xsd @@ -166,6 +166,11 @@ Regular expression pattern to match assets + + + Text before the version in release tags, like "bun-" in "bun-v1.4.2". Releases whose tag does not start with it are ignored, which selects one component of a monorepo (release-please style tags). + + diff --git a/resources/software.schema.json b/resources/software.schema.json index 4489b968..55d12a97 100644 --- a/resources/software.schema.json +++ b/resources/software.schema.json @@ -79,6 +79,10 @@ "asset-pattern": { "type": "string", "description": "Regular expression pattern to match assets" + }, + "tag-prefix": { + "type": "string", + "description": "Text before the version in release tags, like `bun-` in `bun-v1.4.2`. Releases whose tag does not start with it are ignored, which selects one component of a monorepo (release-please style tags)." } } } diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index d1a9357c..d8eac32c 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -5,7 +5,7 @@ How to describe a tool dload does not ship, as a `` block inside `//releases/tags/ \ @@ -38,6 +38,12 @@ Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a wor Patterns are slash-delimited, and in XML attributes backslashes are literal — write `\.exe`. +## `tag-prefix` + +A literal string the release tags start with, cut off before the version is parsed. dload reads the version from the tag, so a tag like `bun-v1.4.2` is skipped as unparsable until `tag-prefix="bun-"` is set. + +It also selects one component of a monorepo with per-component tags (release-please: `cli-v2.0.0`, `sdk-v1.4.0`): releases whose tag does not start with the prefix are ignored. Include the separator in the prefix (`cli-`, `cli/`), leave the `v` out — it is part of the version. + ## `binary.pattern` Matches the **executable on disk after the asset is downloaded and extracted**: diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 7e12fb6d..6cd7ca44 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -13,6 +13,7 @@ curl -s "https://api.github.com/repos///releases?per_page=20" \ - `prerelease: true` while `version` demands stable → lower the stability (`@beta`/`@RC`/`@alpha`). - `draft: true` → invisible to dload until published. +- Tags carry text before the version (`bun-v1.4.2`, `cli-v2.0.0`) → set `tag-prefix` on the ``; without it the tags do not parse and every release is skipped. See [`registry-entry.md`](registry-entry.md#tag-prefix). - Tag is on GitHub but dload picks an older one → the version registry still holds a fresh check; rerun with `dload get --refresh`. A release inserted below the top of the listing (e.g. a GitLab release with a backdated date) needs `dload cache:clear `. See [`version-registry.md`](version-registry.md). - API errors (HTTP 401, 403, rate limit) → set a valid `GITHUB_TOKEN` / `GITLAB_TOKEN`. When a check fails but the registry already holds releases of the repository, dload serves the stored ones (a rate limit is reported as "newer ones may be missing"); a never-seen repository fails outright. diff --git a/src/Module/Config/Schema/Embed/Repository.php b/src/Module/Config/Schema/Embed/Repository.php index 4eb6860d..4aafa5e9 100644 --- a/src/Module/Config/Schema/Embed/Repository.php +++ b/src/Module/Config/Schema/Embed/Repository.php @@ -23,7 +23,8 @@ * @psalm-type RepositoryArray = array{ * type: non-empty-string, * uri: non-empty-string, - * asset-pattern?: non-empty-string + * asset-pattern?: non-empty-string, + * tag-prefix?: string * } */ #[InflectableConfig] @@ -41,6 +42,14 @@ final class Repository #[XPath('@asset-pattern')] public string $assetPattern = '/^.*$/'; + /** + * @var string $tagPrefix Text before the version in release tags, like `bun-` in `bun-v1.4.2`. + * Releases whose tag does not start with it are ignored, which selects one component + * in a monorepo with per-component tags (release-please style). + */ + #[XPath('@tag-prefix')] + public string $tagPrefix = ''; + /** * Creates a Repository configuration from an array. * @@ -52,6 +61,7 @@ public static function fromArray(mixed $repositoryArray): self $self->type = $repositoryArray['type'] ?? 'github'; $self->uri = $repositoryArray['uri']; $self->assetPattern = $repositoryArray['asset-pattern'] ?? '/^.*$/'; + $self->tagPrefix = $repositoryArray['tag-prefix'] ?? ''; return $self; } diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index b009427f..2e712641 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -234,8 +234,7 @@ private function processRepository(Repository $repository, DownloadContext $cont return $context->release; } catch (ReleaseGone $e) { // The registry must not offer this release again, and the list needs a fresh check - $tag = $context->release->getVersion()->string; - $tag === '' or $this->registry->forget($context->repositoryId, $tag); + $this->registry->forget($context->repositoryId, $context->release->getTag()); $forgotten = true; $context->releaseAttempt->reason ??= $e->getMessage(); diff --git a/src/Module/Repository/Internal/GitHub/Factory.php b/src/Module/Repository/Internal/GitHub/Factory.php index 67731539..4001393b 100644 --- a/src/Module/Repository/Internal/GitHub/Factory.php +++ b/src/Module/Repository/Internal/GitHub/Factory.php @@ -52,7 +52,7 @@ public function create(RepositoryConfig $config): GitHubRepository $api = $this->createRepositoryApi($org, $repo); - return new GitHubRepository($api, $org, $repo, $this->logger, $this->registry); + return new GitHubRepository($api, $org, $repo, $this->logger, $this->registry, $config->tagPrefix); } /** diff --git a/src/Module/Repository/Internal/GitHub/GitHubRelease.php b/src/Module/Repository/Internal/GitHub/GitHubRelease.php index adffc49d..291c9c23 100644 --- a/src/Module/Repository/Internal/GitHub/GitHubRelease.php +++ b/src/Module/Repository/Internal/GitHub/GitHubRelease.php @@ -21,25 +21,31 @@ final class GitHubRelease extends Release implements Destroyable { /** * @param non-empty-string $name + * @param non-empty-string $tag */ private function __construct( GitHubRepository $repository, string $name, + string $tag, Version $version, ) { - parent::__construct($repository, $name, $version); + parent::__construct($repository, $name, $tag, $version); } /** + * @param string $tagPrefix Prefix the tag starts with; it is not part of the version. * @throws \InvalidArgumentException When the release tag is not a version. */ public static function fromRecord( RepositoryApi $api, GitHubRepository $repository, ReleaseRecord $record, + string $tagPrefix = '', ): self { - $version = Version::fromVersionString($record->tag); - $result = new self($repository, $record->name, $version); + $version = \substr($record->tag, \strlen($tagPrefix)); + $version === '' and throw new \InvalidArgumentException("Release tag `{$record->tag}` has no version."); + $version = Version::fromVersionString($version); + $result = new self($repository, $record->name, $record->tag, $version); $result->assets = AssetsCollection::create(static function () use ($api, $result, $record): \Generator { foreach ($record->assets as $asset) { diff --git a/src/Module/Repository/Internal/GitHub/GitHubRepository.php b/src/Module/Repository/Internal/GitHub/GitHubRepository.php index a0c80e03..d5033e44 100644 --- a/src/Module/Repository/Internal/GitHub/GitHubRepository.php +++ b/src/Module/Repository/Internal/GitHub/GitHubRepository.php @@ -44,6 +44,7 @@ public function __construct( string $repo, private readonly Logger $logger, private readonly VersionRegistry $registry, + private readonly string $tagPrefix = '', ) { $this->name = $org . '/' . $repo; } @@ -83,8 +84,13 @@ public function getReleases(): ReleasesCollection $toYield = []; foreach ($pages->current() ?? [] as $record) { + // Releases of other components sharing the repository + if (!\str_starts_with($record->tag, $this->tagPrefix)) { + continue; + } + try { - $toYield[] = GitHubRelease::fromRecord($this->api, $this, $record); + $toYield[] = GitHubRelease::fromRecord($this->api, $this, $record, $this->tagPrefix); } catch (\Throwable $e) { $this->logger->exception($e, important: false); // Skip invalid releases diff --git a/src/Module/Repository/Internal/GitLab/Factory.php b/src/Module/Repository/Internal/GitLab/Factory.php index 2ccf6447..83e6106f 100644 --- a/src/Module/Repository/Internal/GitLab/Factory.php +++ b/src/Module/Repository/Internal/GitLab/Factory.php @@ -51,7 +51,7 @@ public function create(RepositoryConfig $config): GitLabRepository $uri = \is_string($path) && $path !== '' ? $path : $config->uri; $api = $this->createRepositoryApi($uri); - return new GitLabRepository($api, $uri, $this->logger, $this->registry); + return new GitLabRepository($api, $uri, $this->logger, $this->registry, $config->tagPrefix); } /** diff --git a/src/Module/Repository/Internal/GitLab/GitLabRelease.php b/src/Module/Repository/Internal/GitLab/GitLabRelease.php index 8760d2c4..e6830e8c 100644 --- a/src/Module/Repository/Internal/GitLab/GitLabRelease.php +++ b/src/Module/Repository/Internal/GitLab/GitLabRelease.php @@ -21,25 +21,31 @@ final class GitLabRelease extends Release implements Destroyable { /** * @param non-empty-string $name + * @param non-empty-string $tag */ private function __construct( GitLabRepository $repository, string $name, + string $tag, Version $version, ) { - parent::__construct($repository, $name, $version); + parent::__construct($repository, $name, $tag, $version); } /** + * @param string $tagPrefix Prefix the tag starts with; it is not part of the version. * @throws \InvalidArgumentException When the release tag is not a version. */ public static function fromRecord( RepositoryApi $api, GitLabRepository $repository, ReleaseRecord $record, + string $tagPrefix = '', ): self { - $version = Version::fromVersionString($record->tag); - $result = new self($repository, $record->name, $version); + $version = \substr($record->tag, \strlen($tagPrefix)); + $version === '' and throw new \InvalidArgumentException("Release tag `{$record->tag}` has no version."); + $version = Version::fromVersionString($version); + $result = new self($repository, $record->name, $record->tag, $version); $result->assets = AssetsCollection::create(static function () use ($api, $result, $record): \Generator { foreach ($record->assets as $asset) { diff --git a/src/Module/Repository/Internal/GitLab/GitLabRepository.php b/src/Module/Repository/Internal/GitLab/GitLabRepository.php index 6410e4a6..58fde09b 100644 --- a/src/Module/Repository/Internal/GitLab/GitLabRepository.php +++ b/src/Module/Repository/Internal/GitLab/GitLabRepository.php @@ -42,6 +42,7 @@ public function __construct( string $projectPath, private readonly Logger $logger, private readonly VersionRegistry $registry, + private readonly string $tagPrefix = '', ) { $this->name = $projectPath; } @@ -81,8 +82,13 @@ public function getReleases(): ReleasesCollection $toYield = []; foreach ($pages->current() ?? [] as $record) { + // Releases of other components sharing the repository + if (!\str_starts_with($record->tag, $this->tagPrefix)) { + continue; + } + try { - $toYield[] = GitLabRelease::fromRecord($this->api, $this, $record); + $toYield[] = GitLabRelease::fromRecord($this->api, $this, $record, $this->tagPrefix); } catch (\Throwable) { // Skip invalid releases continue; diff --git a/src/Module/Repository/Internal/Release.php b/src/Module/Repository/Internal/Release.php index 9e32c7c7..d12d3b33 100644 --- a/src/Module/Repository/Internal/Release.php +++ b/src/Module/Repository/Internal/Release.php @@ -20,10 +20,12 @@ abstract class Release implements ReleaseInterface /** * @param non-empty-string $name Release name. + * @param non-empty-string $tag Tag the release was made from. */ public function __construct( protected Repository $repository, protected string $name, + protected string $tag, protected Version $version, iterable $assets = [], ) { @@ -40,6 +42,11 @@ public function getName(): string return $this->name; } + public function getTag(): string + { + return $this->tag; + } + public function getVersion(): Version { return $this->version; diff --git a/src/Module/Repository/ReleaseInterface.php b/src/Module/Repository/ReleaseInterface.php index 6193ec1a..7c60ad11 100644 --- a/src/Module/Repository/ReleaseInterface.php +++ b/src/Module/Repository/ReleaseInterface.php @@ -36,16 +36,24 @@ interface ReleaseInterface public function getRepository(): Repository; /** - * Returns Composer's compatible "pretty" release version. + * Returns the human-readable release name. * - * This version is formatted for semantic versioning compatibility. - * - * @return non-empty-string Formatted version string (e.g. "1.2.3") + * @return non-empty-string */ public function getName(): string; /** - * Returns the version of this release. + * Returns the tag the release was made from, exactly as the repository reports it. + * + * Unlike the version, it keeps the configured tag prefix: it identifies the release + * within the repository and in the version registry. + * + * @return non-empty-string + */ + public function getTag(): string; + + /** + * Returns the version of this release, parsed from the tag without the tag prefix. */ public function getVersion(): Version; diff --git a/tests/Unit/Module/Config/Schema/Embed/RepositoryTest.php b/tests/Unit/Module/Config/Schema/Embed/RepositoryTest.php new file mode 100644 index 00000000..88975c95 --- /dev/null +++ b/tests/Unit/Module/Config/Schema/Embed/RepositoryTest.php @@ -0,0 +1,30 @@ + 'github', 'uri' => 'oven-sh/bun', 'tag-prefix' => 'bun-']); + + Assert::same($repository->tagPrefix, 'bun-'); + } + + #[Test] + public function tagPrefixIsEmptyByDefault(): void + { + $repository = Repository::fromArray(['type' => 'github', 'uri' => 'owner/repo']); + + Assert::same($repository->tagPrefix, ''); + } +} diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 266f92ce..8c6fa654 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -73,6 +73,20 @@ public function deletedReleaseIsForgottenAndTheNextOneIsUsed(): void Assert::same($this->registry->attached, [['rr', 'github:owner/repo']]); } + #[Test] + public function deletedReleaseIsForgottenByItsTagWithThePrefix(): void + { + $repository = new RepositoryStub('owner/repo'); + $gone = new ReleaseStub($repository, 'Bun v2.0.0', Version::fromVersionString('v2.0.0'), tag: 'bun-v2.0.0'); + $gone->setAssets([new GoneAssetStub($gone, 'rr-linux-amd64.tar.gz')]); + $alive = self::release($repository, 'v1.9.0', assets: true); + $repository = new RepositoryStub('owner/repo', ReleasesCollection::create([$gone, $alive])); + + $this->download([$repository]); + + Assert::same($this->registry->forgotten, [['github:owner/repo', 'bun-v2.0.0']]); + } + #[Test] public function registryIdComesFromTheRepositoryNotTheConfiguredUri(): void { diff --git a/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php b/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php index aa837eb9..e423e12a 100644 --- a/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php +++ b/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php @@ -207,6 +207,38 @@ public function aReleaseWithAnUnparsableTagIsSkipped(): void Assert::same($names, ['v1.0.1']); } + #[Test] + public function tagPrefixSelectsReleasesAndIsNotPartOfTheVersion(): void + { + $registry = new ScriptedRegistryStub([[ + self::record('cli-v2.0.0'), + self::record('bun-v1.4.2'), + self::record('v1.0.0'), + self::record('bun-v1.4.1-canary.1'), + ]]); + $repository = self::createRepository(new PagedClientStub(pages: 1), $registry, tagPrefix: 'bun-'); + + $releases = \iterator_to_array($repository->getReleases(), false); + + Assert::same( + \array_map(static fn(ReleaseInterface $release): string => $release->getTag(), $releases), + ['bun-v1.4.2', 'bun-v1.4.1-canary.1'], + ); + Assert::same( + \array_map(static fn(ReleaseInterface $release): string => $release->getVersion()->string, $releases), + ['v1.4.2', 'v1.4.1-canary.1'], + ); + } + + #[Test] + public function aTagConsistingOfThePrefixOnlyIsSkipped(): void + { + $registry = new ScriptedRegistryStub([[self::record('bun-'), self::record('bun-v1.0.1')]]); + $repository = self::createRepository(new PagedClientStub(pages: 1), $registry, tagPrefix: 'bun-'); + + Assert::same(self::names($repository), ['bun-v1.0.1']); + } + #[Test] public function aFailureOfTheFirstPageReachesTheCaller(): void { @@ -256,8 +288,9 @@ private static function record(string $tag): ReleaseRecord private static function createRepository( PagedClientStub $client, VersionRegistry $registry = new PassThroughRegistry(), + string $tagPrefix = '', ): GitHubRepository { - return new GitHubRepository(self::api($client), 'owner', 'repo', new Logger(), $registry); + return new GitHubRepository(self::api($client), 'owner', 'repo', new Logger(), $registry, $tagPrefix); } private static function api(PagedClientStub $client): RepositoryApi diff --git a/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php b/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php index 8394045e..3681e324 100644 --- a/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php +++ b/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php @@ -161,6 +161,38 @@ public function aReleaseWithAnUnparsableTagIsSkipped(): void Assert::same($names, ['v1.0.1']); } + #[Test] + public function tagPrefixSelectsReleasesAndIsNotPartOfTheVersion(): void + { + $registry = new ScriptedRegistryStub([[ + self::record('cli-v2.0.0'), + self::record('bun-v1.4.2'), + self::record('v1.0.0'), + self::record('bun-v1.4.1-canary.1'), + ]]); + $repository = self::createRepository(new PagedClientStub(pages: 1), $registry, tagPrefix: 'bun-'); + + $releases = \iterator_to_array($repository->getReleases(), false); + + Assert::same( + \array_map(static fn(ReleaseInterface $release): string => $release->getTag(), $releases), + ['bun-v1.4.2', 'bun-v1.4.1-canary.1'], + ); + Assert::same( + \array_map(static fn(ReleaseInterface $release): string => $release->getVersion()->string, $releases), + ['v1.4.2', 'v1.4.1-canary.1'], + ); + } + + #[Test] + public function aTagConsistingOfThePrefixOnlyIsSkipped(): void + { + $registry = new ScriptedRegistryStub([[self::record('bun-'), self::record('bun-v1.0.1')]]); + $repository = self::createRepository(new PagedClientStub(pages: 1), $registry, tagPrefix: 'bun-'); + + Assert::same(self::names($repository), ['bun-v1.0.1']); + } + #[Test] public function aFailureOfTheFirstPageReachesTheCaller(): void { @@ -210,6 +242,7 @@ private static function record(string $tag): ReleaseRecord private static function createRepository( PagedClientStub $client, VersionRegistry $registry = new PassThroughRegistry(), + string $tagPrefix = '', ): GitLabRepository { $logger = new Logger(); $httpFactory = new NyholmFactoryImpl($logger); @@ -219,7 +252,7 @@ private static function createRepository( 'group/project', ); - return new GitLabRepository($api, 'group/project', $logger, $registry); + return new GitLabRepository($api, 'group/project', $logger, $registry, $tagPrefix); } private static function registry(InMemoryRegistryStorage $storage): StoredVersionRegistry diff --git a/tests/Unit/Module/Repository/Stub/ReleaseStub.php b/tests/Unit/Module/Repository/Stub/ReleaseStub.php index 09f85b6f..1fa9d336 100644 --- a/tests/Unit/Module/Repository/Stub/ReleaseStub.php +++ b/tests/Unit/Module/Repository/Stub/ReleaseStub.php @@ -19,12 +19,14 @@ final class ReleaseStub implements ReleaseInterface /** * @param non-empty-string $name Formatted version (e.g. "1.2.3") * @param array $assets + * @param non-empty-string|null $tag Defaults to the name */ public function __construct( private readonly RepositoryStub $repository, private readonly string $name, private readonly Version $version, private array $assets = [], + private readonly ?string $tag = null, ) {} public function getRepository(): Repository @@ -37,6 +39,11 @@ public function getName(): string return $this->name; } + public function getTag(): string + { + return $this->tag ?? $this->name; + } + public function getVersion(): Version { return $this->version; From ee246ba57248b26b5b21324914b664ac5233c0d1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 16:22:15 +0400 Subject: [PATCH 2/3] feat(registry): add Bun to the default software registry The asset pattern is narrow on purpose: without variant ranking (#134) a broad one picks the profile, baseline or Android build. `AssetSelectionTest` keeps Bun's real asset list as the acceptance check for that ranking. Closes #130 Assisted-By: Claude Opus 5.5 --- resources/software.json | 18 ++ .../Module/Downloader/AssetSelectionTest.php | 210 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 tests/Unit/Module/Downloader/AssetSelectionTest.php diff --git a/resources/software.json b/resources/software.json index de666e2b..0a58283e 100644 --- a/resources/software.json +++ b/resources/software.json @@ -276,6 +276,24 @@ "name": "deno", "version-command": "--version" } + }, + { + "name": "Bun", + "alias": "bun", + "description": "Incredibly fast JavaScript runtime, bundler, test runner, and package manager - all in one.", + "homepage": "https://bun.com", + "repositories": [ + { + "type": "github", + "uri": "oven-sh/bun", + "tag-prefix": "bun-", + "asset-pattern": "/^bun-[a-z]+-(?:x64|aarch64)\\.zip$/" + } + ], + "binary": { + "name": "bun", + "version-command": "--version" + } } ] } diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php new file mode 100644 index 00000000..c6a62721 --- /dev/null +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -0,0 +1,210 @@ + [OperatingSystem::Linux, Architecture::X86_64, 'bun-linux-x64.zip']; + yield 'Linux arm64' => [OperatingSystem::Linux, Architecture::ARM_64, 'bun-linux-aarch64.zip']; + yield 'macOS x64' => [OperatingSystem::Darwin, Architecture::X86_64, 'bun-darwin-x64.zip']; + yield 'macOS arm64' => [OperatingSystem::Darwin, Architecture::ARM_64, 'bun-darwin-aarch64.zip']; + yield 'Windows x64' => [OperatingSystem::Windows, Architecture::X86_64, 'bun-windows-x64.zip']; + yield 'Windows arm64' => [OperatingSystem::Windows, Architecture::ARM_64, 'bun-windows-aarch64.zip']; + yield 'FreeBSD x64' => [OperatingSystem::BSD, Architecture::X86_64, 'bun-freebsd-x64.zip']; + yield 'FreeBSD arm64' => [OperatingSystem::BSD, Architecture::ARM_64, 'bun-freebsd-aarch64.zip']; + } + + #[DataProvider('provideBunHosts')] + #[Test] + public function bunRegistryEntrySelectsThePlainBuild( + OperatingSystem $os, + Architecture $arch, + string $expected, + ): void { + $result = $this->downloadBun(self::registryEntry('bun'), $os, $arch); + + Assert::same($result->file->getFilename(), $expected); + } + + /** + * The target of variant ranking: the plain build wins without a pattern that spells out + * the asset shape of one tool. + */ + #[DataProvider('provideBunHosts')] + #[Test] + public function bunPlainBuildIsSelectedWithABroadAssetPattern( + OperatingSystem $os, + Architecture $arch, + string $expected, + ): void { + $software = Software::fromArray([ + 'name' => 'Bun', + 'alias' => 'bun', + 'repositories' => [['type' => 'github', 'uri' => 'oven-sh/bun', 'asset-pattern' => '/^bun-.*/']], + 'binary' => ['name' => 'bun'], + ]); + + $result = $this->downloadBun($software, $os, $arch); + + $result->file->getFilename() === $expected or throw new SkipTest(\sprintf( + 'Picks `%s`: asset variants are not ranked yet, see https://github.com/php-internal/dload/issues/134', + $result->file->getFilename(), + )); + Assert::same($result->file->getFilename(), $expected); + } + + #[BeforeTest] + protected function setUp(): void + { + $this->tempDir = \sys_get_temp_dir() . '/dload-asset-selection-' . \bin2hex(\random_bytes(6)); + \mkdir($this->tempDir, recursive: true); + } + + #[AfterTest] + protected function cleanup(): void + { + \is_dir($this->tempDir) and FS::removeDir(Path::create($this->tempDir)); + } + + /** + * @param non-empty-string $alias + */ + private static function registryEntry(string $alias): Software + { + /** @var array{software: list} $registry */ + $registry = \json_decode( + (string) \file_get_contents(\dirname(__DIR__, 4) . '/resources/software.json'), + true, + flags: \JSON_THROW_ON_ERROR, + ); + + foreach ($registry['software'] as $entry) { + if (($entry['alias'] ?? null) === $alias) { + return Software::fromArray($entry); + } + } + + throw new \LogicException("No `$alias` in the software registry."); + } + + private function downloadBun(Software $software, OperatingSystem $os, Architecture $arch): DownloadResult + { + $repository = new RepositoryStub('oven-sh/bun'); + $release = new ReleaseStub($repository, 'Bun v1.4.2', Version::fromVersionString('v1.4.2'), tag: 'bun-v1.4.2'); + $release->setAssets(\array_map( + static fn(string $name): AssetStub => new AssetStub( + $release, + $name, + 'https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/' . $name, + OperatingSystem::tryFromBuildName($name), + Architecture::tryFromBuildName($name), + ), + self::BUN_ASSETS, + )); + $repository = new RepositoryStub('oven-sh/bun', ReleasesCollection::create([$release])); + + $config = new DownloaderConfig(); + $config->tmpDir = $this->tempDir; + + $downloader = new Downloader( + config: $config, + logger: new Logger(), + repositoryProvider: (new RepositoryProvider())->addRepositoryFactory( + new SequenceRepositoryFactoryStub([$repository]), + ), + architecture: $arch, + operatingSystem: $os, + stability: Stability::Stable, + archiveService: new ArchiveFactory(), + registry: new RecordingRegistry(), + ); + $task = $downloader->download($software, DownloadConfig::fromSoftwareId('bun'), static fn(): null => null); + + /** @var DownloadResult */ + return await(($task->handler)()); + } +} From 612bd96e3bfd20d7f4a77149a015b6d69a272664 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 17:44:47 +0400 Subject: [PATCH 3/3] fix(gitlab): download release assets by the tag, not the release name GitLab addresses release downloads by tag, so a release whose name differs from its tag (`CLI 1.2.3` for `cli/v1.2.3`) failed every asset download. The tag is URL-encoded as a single path segment, which a prefix like `cli/` requires. Assisted-By: Claude Opus 5.5 --- .../Internal/GitLab/Api/RepositoryApi.php | 6 ++--- .../Internal/GitLab/GitLabAsset.php | 2 +- .../Internal/GitLab/GitLabRepositoryTest.php | 24 +++++++++++++++++++ .../Internal/GitLab/Stub/PagedClientStub.php | 8 +++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php index 5dec6646..d5a42c53 100644 --- a/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php +++ b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php @@ -52,13 +52,13 @@ public function __construct( /** * @param non-empty-string $repositoryPath - * @param non-empty-string $releaseName + * @param non-empty-string $tag Tag of the release; GitLab addresses release downloads by it, not by the release name. * @param non-empty-string $fileName * @throws RepositoryException */ - public function downloadArtifact(string $repositoryPath, string $releaseName, string $fileName): ResponseInterface + public function downloadArtifact(string $repositoryPath, string $tag, string $fileName): ResponseInterface { - $url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), $releaseName, $fileName); + $url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), \rawurlencode($tag), $fileName); return $this->client->downloadArtifact($url); } diff --git a/src/Module/Repository/Internal/GitLab/GitLabAsset.php b/src/Module/Repository/Internal/GitLab/GitLabAsset.php index 927c1b6d..67350b98 100644 --- a/src/Module/Repository/Internal/GitLab/GitLabAsset.php +++ b/src/Module/Repository/Internal/GitLab/GitLabAsset.php @@ -59,7 +59,7 @@ public static function fromRecord( */ public function download(?\Closure $progress = null): \Generator { - $response = $this->api->downloadArtifact($this->release->getRepository()->getName(), $this->release->getName(), $this->getName()); + $response = $this->api->downloadArtifact($this->release->getRepository()->getName(), $this->release->getTag(), $this->getName()); yield from StreamReader::chunks($response->getBody(), $progress); } diff --git a/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php b/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php index 3681e324..5f27f681 100644 --- a/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php +++ b/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php @@ -8,6 +8,7 @@ use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl; use Internal\DLoad\Module\Registry\Internal\PassThroughRegistry; use Internal\DLoad\Module\Registry\Internal\StoredVersionRegistry; +use Internal\DLoad\Module\Registry\Record\AssetRecord; use Internal\DLoad\Module\Registry\Record\ReleaseRecord; use Internal\DLoad\Module\Registry\Record\RepositoryRecord; use Internal\DLoad\Module\Registry\RepositoryId; @@ -15,6 +16,7 @@ use Internal\DLoad\Module\Repository\Exception\RateLimitException; use Internal\DLoad\Module\Repository\Internal\GitLab\Api\Client; use Internal\DLoad\Module\Repository\Internal\GitLab\Api\RepositoryApi; +use Internal\DLoad\Module\Repository\Internal\GitLab\GitLabAsset; use Internal\DLoad\Module\Repository\Internal\GitLab\GitLabReleaseSource; use Internal\DLoad\Module\Repository\Internal\GitLab\GitLabRepository; use Internal\DLoad\Module\Repository\ReleaseInterface; @@ -29,6 +31,7 @@ #[Covers(GitLabRepository::class)] #[Covers(GitLabReleaseSource::class)] +#[Covers(GitLabAsset::class)] final class GitLabRepositoryTest { #[Test] @@ -184,6 +187,27 @@ public function tagPrefixSelectsReleasesAndIsNotPartOfTheVersion(): void ); } + #[Test] + public function assetsAreDownloadedByTheTagNotTheReleaseName(): void + { + $client = new PagedClientStub(pages: 1); + $registry = new ScriptedRegistryStub([[new ReleaseRecord( + 'cli/v1.2.3', + 'CLI 1.2.3', + assets: [new AssetRecord('cli-linux-amd64.tar.gz', 'https://gitlab.com/group/project/-/releases/cli%2Fv1.2.3/downloads/cli-linux-amd64.tar.gz')], + )]]); + $repository = self::createRepository($client, $registry, tagPrefix: 'cli/'); + $asset = $repository->getReleases()->first()?->getAssets()->first(); + Assert::notNull($asset); + + \iterator_to_array($asset->download(), false); + + Assert::same( + \end($client->uris), + 'https://gitlab.com/api/v4/projects/group%2Fproject/releases/cli%2Fv1.2.3/downloads/cli-linux-amd64.tar.gz', + ); + } + #[Test] public function aTagConsistingOfThePrefixOnlyIsSkipped(): void { diff --git a/tests/Unit/Module/Repository/Internal/GitLab/Stub/PagedClientStub.php b/tests/Unit/Module/Repository/Internal/GitLab/Stub/PagedClientStub.php index eca2b51f..ca644008 100644 --- a/tests/Unit/Module/Repository/Internal/GitLab/Stub/PagedClientStub.php +++ b/tests/Unit/Module/Repository/Internal/GitLab/Stub/PagedClientStub.php @@ -25,6 +25,13 @@ final class PagedClientStub implements ClientInterface */ public array $requests = []; + /** + * Full URI of every received request, in order. + * + * @var list + */ + public array $uris = []; + /** * @param int<1, max> $pages Number of pages the list is split into when 100 releases are requested per page. * @param int<1, max> $releasesPerPage Number of releases on every such page. @@ -38,6 +45,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface { $query = $request->getUri()->getQuery(); $this->requests[] = $query; + $this->uris[] = (string) $request->getUri(); $page = self::pageOf($query); $perPage = self::perPageOf($query);