From 58a514c5228357861267b96a33e1c916d6befeef Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 19:29:35 +0400 Subject: [PATCH 1/5] fix(downloader): skip OS packages when a binary is expected docs: note that OS packages are not selected for a binary A selected package downloads fine and only fails the installation with nothing extracted, after the release loop has ended, so an older release with an archive was never tried. Dropping packages at selection turns that release into "no matching asset" and the downloader moves on. Assisted-By: Claude Opus 5.5 --- README-es.md | 3 + README-ru.md | 3 + README-zh.md | 3 + README.md | 3 + .../references/registry-entry.md | 2 +- .../Internal/AssetSelection/AssetSelector.php | 2 + .../AssetSelection/Rule/PackageRule.php | 34 +++++++++++ .../AssetSelection/AssetSelectorTest.php | 48 ++++++++++++++- .../AssetSelection/Rule/PackageRuleTest.php | 59 +++++++++++++++++++ 9 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php diff --git a/README-es.md b/README-es.md index b234a5ca..ddea0a7e 100644 --- a/README-es.md +++ b/README-es.md @@ -294,6 +294,9 @@ Cuando no se especifica `type`, DLoad automáticamente usa todos los manejadores > Usa `type="phar"` para herramientas PHP que deben mantenerse como archivos `.phar`. > Usar `type="archive"` extraerá incluso archivos `.phar`. +> [!NOTE] +> Cuando el software tiene un `` y el tipo no es `phar`, los paquetes del sistema operativo (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) nunca se seleccionan: DLoad no puede desempaquetarlos. Un release que solo ofrece un paquete para el host se omite en favor de uno anterior. + #### Extracción de Archivos (Preservando la Estructura) `type="archive"` descomprime el asset **completo** en `extract-path`, manteniendo la estructura interna de directorios del archivo en lugar de aplanar los archivos coincidentes en una sola carpeta: diff --git a/README-ru.md b/README-ru.md index 9c006242..6c1901b4 100644 --- a/README-ru.md +++ b/README-ru.md @@ -295,6 +295,9 @@ DLoad поддерживает три типа загрузки, которые > Используйте `type="phar"` для PHP-инструментов, которые должны остаться как `.phar` файлы. > Использование `type="archive"` распакует даже `.phar` архивы. +> [!NOTE] +> Когда у ПО есть ``, а тип не `phar`, пакеты ОС (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) никогда не выбираются: DLoad не умеет их распаковывать. Релиз, в котором для хоста есть только пакет, пропускается в пользу более старого. + #### Распаковка архива с сохранением структуры `type="archive"` распаковывает **весь** ассет в `extract-path`, сохраняя внутреннюю структуру каталогов архива, а не сплющивая совпавшие файлы в одну папку: diff --git a/README-zh.md b/README-zh.md index 3c4c3fd4..f93ea85a 100644 --- a/README-zh.md +++ b/README-zh.md @@ -294,6 +294,9 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: > 对于应该保持为 `.phar` 文件的 PHP 工具,使用 `type="phar"`。 > 使用 `type="archive"` 会解包甚至 `.phar` 压缩包。 +> [!NOTE] +> 当软件定义了 `` 且类型不是 `phar` 时,永远不会选择操作系统安装包(`.deb`、`.rpm`、`.apk`、`.msi`、`.dmg`、`.pkg`):DLoad 无法解包它们。如果某个发布版本只为当前主机提供安装包,则会跳过它,改用更早的版本。 + #### 解包压缩包(保留结构) `type="archive"` 会将**整个**资源解包到 `extract-path`,保留压缩包内部的目录结构,而不是把匹配的文件平铺到单个目录中: diff --git a/README.md b/README.md index ef308c48..39fba0ed 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,9 @@ When `type` is not specified, DLoad automatically uses all available handlers: > Use `type="phar"` for PHP tools that should remain as `.phar` files. > Using `type="archive"` will unpack even `.phar` archives. +> [!NOTE] +> When the software has a `` and the type is not `phar`, OS packages (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) are never selected: DLoad cannot unpack them. A release that offers only a package for the host is skipped in favour of an older one. + #### Archive Extraction (Preserving Structure) `type="archive"` unpacks the **entire** asset into `extract-path`, keeping the archive's internal directory layout instead of flattening matched files into a single folder: diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 2659a959..4afd6ed6 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,7 +22,7 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. -Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower). On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower), and so are OS packages (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) everywhere: dload cannot unpack them, so a release that has only a package for the host gives way to an older release. On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index 26bcd209..6fa4244a 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -17,6 +17,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Module\Repository\AssetInterface; use Psr\Container\ContainerInterface; @@ -51,6 +52,7 @@ public function __construct( $pipeline = Pipeline::prepare( new NamePatternRule(), new FormatRule($archiveFactory), + new PackageRule(), new CompanionRule(), new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture, $operatingSystem), diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php new file mode 100644 index 00000000..25e65b6b --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php @@ -0,0 +1,34 @@ +strict and $selection = $selection->remove( + static fn(Candidate $candidate): bool => $candidate->hasExtension(self::EXTENSIONS), + ); + + return $next($selection); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 364fbf95..c4149d2e 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -19,6 +19,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Module\Repository\AssetInterface; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\NamedAssets; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; @@ -29,6 +30,7 @@ #[Covers(AssetSelector::class)] #[Covers(NamePatternRule::class)] #[Covers(FormatRule::class)] +#[Covers(PackageRule::class)] #[Covers(CompanionRule::class)] #[Covers(OperatingSystemRule::class)] #[Covers(ArchitectureRule::class)] @@ -206,11 +208,55 @@ public function aChecksumNeverOutranksABuild(): void #[Test] public function archivesComeBeforeOtherFiles(): void { - $names = self::select(['tool-linux-amd64', 'tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz']); + $names = self::select(['tool-linux-amd64', 'tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz'], strict: false); Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64', 'tool-linux-amd64.deb']); } + /** + * An empty selection sends the downloader to the next release; a selected package would be + * downloaded and then fail the installation with nothing extracted. + */ + #[Test] + public function osPackagesAreDroppedWhenABinaryIsExpected(): void + { + $names = self::select(['tool-linux-amd64.deb', 'tool-linux-amd64.rpm', 'tool-linux-amd64.apk']); + + Assert::same($names, []); + } + + #[Test] + public function archivesAndPlainBinariesOutliveOsPackages(): void + { + $names = self::select(['tool-linux-amd64', 'tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz']); + + Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64']); + } + + #[Test] + public function aBinaryActionDropsOsPackagesToo(): void + { + $names = self::select(['tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz'], type: Type::Binary); + + Assert::same($names, ['tool-linux-amd64.tar.gz']); + } + + #[Test] + public function aWindowsExecutableOutlivesTheInstaller(): void + { + $names = self::select(['tool-windows-amd64.exe', 'tool-windows-amd64.msi'], os: OperatingSystem::Windows); + + Assert::same($names, ['tool-windows-amd64.exe']); + } + + #[Test] + public function osPackagesAreKeptWhenNoBinaryIsExpected(): void + { + $names = self::select(['tool-linux-amd64.deb'], strict: false); + + Assert::same($names, ['tool-linux-amd64.deb']); + } + /** * @param list $assets * @param non-empty-string $pattern diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php new file mode 100644 index 00000000..d4ac9b33 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php @@ -0,0 +1,59 @@ + Date: Sat, 10 Oct 2026 20:36:00 +0400 Subject: [PATCH 2/5] fix(downloader): name the skipped OS packages in the failure report fix(downloader): skip snap, flatpak, msix, appx and nupkg packages too test(downloader): cover the fallback to an older release over a package docs: list every skipped package format and the new report lines Assisted-By: Claude Opus 5.5 --- README-es.md | 2 +- README-ru.md | 2 +- README-zh.md | 2 +- README.md | 2 +- .../references/registry-entry.md | 2 +- .../references/troubleshooting.md | 2 + src/Module/Downloader/Downloader.php | 10 ++- .../Internal/AssetSelection/AssetSelector.php | 3 +- .../AssetSelection/Rule/PackageRule.php | 9 +- .../Internal/AssetSelection/Selection.php | 30 +++++-- .../Internal/Diagnostics/ReleaseAttempt.php | 8 ++ .../Unit/Module/Downloader/DownloaderTest.php | 90 +++++++++++++++++-- .../AssetSelection/AssetSelectorTest.php | 8 -- .../AssetSelection/Rule/PackageRuleTest.php | 36 +++++--- .../Internal/AssetSelection/SelectionTest.php | 18 ++++ 15 files changed, 182 insertions(+), 42 deletions(-) diff --git a/README-es.md b/README-es.md index ddea0a7e..90e53e5f 100644 --- a/README-es.md +++ b/README-es.md @@ -295,7 +295,7 @@ Cuando no se especifica `type`, DLoad automáticamente usa todos los manejadores > Usar `type="archive"` extraerá incluso archivos `.phar`. > [!NOTE] -> Cuando el software tiene un `` y el tipo no es `phar`, los paquetes del sistema operativo (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) nunca se seleccionan: DLoad no puede desempaquetarlos. Un release que solo ofrece un paquete para el host se omite en favor de uno anterior. +> Cuando hay un `` configurado, con `type="binary"` o sin `type`, los paquetes del sistema operativo (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) nunca se seleccionan: DLoad no puede desempaquetarlos. Un release que solo ofrece un paquete para el host se omite, y DLoad toma el siguiente release más antiguo que siga cumpliendo la restricción de versión. #### Extracción de Archivos (Preservando la Estructura) diff --git a/README-ru.md b/README-ru.md index 6c1901b4..0c559a4e 100644 --- a/README-ru.md +++ b/README-ru.md @@ -296,7 +296,7 @@ DLoad поддерживает три типа загрузки, которые > Использование `type="archive"` распакует даже `.phar` архивы. > [!NOTE] -> Когда у ПО есть ``, а тип не `phar`, пакеты ОС (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) никогда не выбираются: DLoad не умеет их распаковывать. Релиз, в котором для хоста есть только пакет, пропускается в пользу более старого. +> Если задан `` — с `type="binary"` или без `type`, — пакеты ОС (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) никогда не выбираются: DLoad не умеет их распаковывать. Релиз, в котором для хоста есть только пакет, пропускается, и DLoad берёт следующий более старый релиз, который по-прежнему удовлетворяет ограничению версии. #### Распаковка архива с сохранением структуры diff --git a/README-zh.md b/README-zh.md index f93ea85a..da359423 100644 --- a/README-zh.md +++ b/README-zh.md @@ -295,7 +295,7 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: > 使用 `type="archive"` 会解包甚至 `.phar` 压缩包。 > [!NOTE] -> 当软件定义了 `` 且类型不是 `phar` 时,永远不会选择操作系统安装包(`.deb`、`.rpm`、`.apk`、`.msi`、`.dmg`、`.pkg`):DLoad 无法解包它们。如果某个发布版本只为当前主机提供安装包,则会跳过它,改用更早的版本。 +> 配置了 `` 时(无论是 `type="binary"` 还是未指定 `type`),永远不会选择操作系统安装包(`.deb`、`.rpm`、`.apk`、`.snap`、`.flatpak`、`.msi`、`.msix`、`.msixbundle`、`.appx`、`.appxbundle`、`.nupkg`、`.dmg`、`.pkg`):DLoad 无法解包它们。如果某个发布版本只为当前主机提供安装包,则会跳过它,改用下一个仍满足版本约束的更早版本。 #### 解包压缩包(保留结构) diff --git a/README.md b/README.md index 39fba0ed..848e656a 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ When `type` is not specified, DLoad automatically uses all available handlers: > Using `type="archive"` will unpack even `.phar` archives. > [!NOTE] -> When the software has a `` and the type is not `phar`, OS packages (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) are never selected: DLoad cannot unpack them. A release that offers only a package for the host is skipped in favour of an older one. +> When a `` is configured, with `type="binary"` or without a `type`, OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) are never selected: DLoad cannot unpack them. A release that offers only a package for the host is skipped, and DLoad takes the next older release that still satisfies the version constraint. #### Archive Extraction (Preserving Structure) diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 4afd6ed6..4e5fd52b 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,7 +22,7 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. -Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower), and so are OS packages (`.deb`, `.rpm`, `.apk`, `.msi`, `.dmg`, `.pkg`) everywhere: dload cannot unpack them, so a release that has only a package for the host gives way to an older release. On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower), and so are OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) everywhere: dload cannot unpack them, so a release that has only a package for the host gives way to the next older release that satisfies `version`. On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 3d8cc34e..77271fae 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -44,6 +44,8 @@ A candidate that matches neither is discarded. Common offenders: Compare with the host: `php -r "echo PHP_OS_FAMILY, ' / ', php_uname('m'), PHP_EOL;"`. +With a `binary`, OS packages for the host (`.deb`, `.msi`… — the list is in [`registry-entry.md`](registry-entry.md#asset-pattern)) are discarded too, since dload cannot unpack them, and a release offering only packages is skipped for the next older one. The report then says "only OS packages match" and lists them under "OS packages are skipped because a binary is expected"; when no older release fits, pin a `version` that ships an archive or a raw binary for the host. + Several candidates left: dload tries them best first — host libc, then the fewest extra name tokens, then archives, then the release order. Run with `-vvv` to see the rank of every asset (`Asset ... ranked: os=0 arch=0 libc=0 extras=1 archive=0`); a wrong pick usually means the wanted build carries an extra token the others lack. ## Stage 4 — binary extraction diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 203cad75..99022259 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -19,7 +19,9 @@ use Internal\DLoad\Module\Downloader\Exception\ReleaseGone; use Internal\DLoad\Module\Downloader\Internal\Diagnostics\DownloadDiagnostics; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Candidate; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Module\Downloader\Internal\DownloadContext; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; @@ -299,10 +301,16 @@ private function processRelease(DownloadContext $context): \Closure ); $this->logger->debug('%d matching assets found.', \count($selection->candidates)); + $context->releaseAttempt->skippedPackages = \array_map( + static fn(Candidate $candidate): string => $candidate->asset->getName(), + $selection->removed[PackageRule::KEY] ?? [], + ); + $selection->isEmpty() and throw new NotFound( $strict ? \sprintf( - 'no asset matches OS `%s`, architecture `%s`, name pattern `%s`%s', + '%s OS `%s`, architecture `%s`, name pattern `%s`%s', + $context->releaseAttempt->skippedPackages === [] ? 'no asset matches' : 'only OS packages match', $this->operatingSystem->value, $this->architecture->value, $context->repoConfig->assetPattern, diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index 6fa4244a..acc31bfd 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -52,10 +52,11 @@ public function __construct( $pipeline = Pipeline::prepare( new NamePatternRule(), new FormatRule($archiveFactory), - new PackageRule(), new CompanionRule(), new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture, $operatingSystem), + // After the platform rules: the packages it removes are then the host's own, which the report names + new PackageRule(), new LibcRule($container), new ExtrasRule(), new ArchiveRule($archiveFactory), diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php index 25e65b6b..29bdf75c 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php @@ -21,12 +21,19 @@ */ final class PackageRule implements AssetRule { - private const EXTENSIONS = ['deb', 'rpm', 'apk', 'msi', 'dmg', 'pkg']; + /** Reason the packages are removed under, see {@see Selection::$removed}. */ + public const KEY = 'package'; + + private const EXTENSIONS = [ + 'deb', 'rpm', 'apk', 'msi', 'dmg', 'pkg', + 'snap', 'flatpak', 'msix', 'msixbundle', 'appx', 'appxbundle', 'nupkg', + ]; public function select(Selection $selection, callable $next): Selection { $selection->strict and $selection = $selection->remove( static fn(Candidate $candidate): bool => $candidate->hasExtension(self::EXTENSIONS), + self::KEY, ); return $next($selection); diff --git a/src/Module/Downloader/Internal/AssetSelection/Selection.php b/src/Module/Downloader/Internal/AssetSelection/Selection.php index 6bc105a3..b064e247 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Selection.php +++ b/src/Module/Downloader/Internal/AssetSelection/Selection.php @@ -24,12 +24,15 @@ final class Selection * @param non-empty-string $assetPattern Pattern the asset names must match. * @param Type|null $type Download action type restricting the asset format. * @param bool $strict Whether assets for another OS or architecture are removed rather than ranked lower. + * @param array> $removed Candidates removed with a reason, by the reason, + * for a failure report to name them. */ private function __construct( public readonly array $candidates, public readonly string $assetPattern, public readonly ?Type $type, public readonly bool $strict, + public readonly array $removed = [], ) {} /** @@ -53,13 +56,25 @@ public static function create(iterable $assets, string $assetPattern, ?Type $typ * is a rank, see {@see self::rank()}. * * @param \Closure(Candidate): bool $predicate + * @param non-empty-string|null $reason Key to keep the removed candidates under, see {@see self::$removed}. */ - public function remove(\Closure $predicate): self + public function remove(\Closure $predicate, ?string $reason = null): self { - return $this->withCandidates(\array_values(\array_filter( - $this->candidates, - static fn(Candidate $candidate): bool => !$predicate($candidate), - ))); + $kept = $removed = []; + foreach ($this->candidates as $candidate) { + if ($predicate($candidate)) { + $removed[] = $candidate; + } else { + $kept[] = $candidate; + } + } + + return $this->withCandidates( + $kept, + $reason === null || $removed === [] + ? $this->removed + : [...$this->removed, $reason => [...$this->removed[$reason] ?? [], ...$removed]], + ); } /** @@ -121,9 +136,10 @@ public function assets(): array /** * @param list $candidates + * @param array>|null $removed */ - private function withCandidates(array $candidates): self + private function withCandidates(array $candidates, ?array $removed = null): self { - return new self($candidates, $this->assetPattern, $this->type, $this->strict); + return new self($candidates, $this->assetPattern, $this->type, $this->strict, $removed ?? $this->removed); } } diff --git a/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php b/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php index ec0c57e0..aaacef03 100644 --- a/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php +++ b/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php @@ -20,6 +20,9 @@ final class ReleaseAttempt /** @var string|null Why the release was rejected */ public ?string $reason = null; + /** @var list Names of the OS packages for the host, skipped because a binary is expected */ + public array $skippedPackages = []; + /** @var list Names of all assets in the release */ private array $assetNames = []; @@ -79,6 +82,11 @@ public function describe(): array ); } + $this->skippedPackages === [] or $lines[] = \sprintf( + ' OS packages are skipped because a binary is expected: %s', + \implode(', ', $this->skippedPackages), + ); + foreach ($this->failures as $failure) { $lines[] = ' Failed asset ' . $failure; } diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 45b01680..3af09caf 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -241,6 +241,56 @@ public function strictFilteringFailsWhenNoAssetMatchesAllCriteria(): void } } + #[Test] + public function aReleaseWithOnlyAPackageForTheHostGivesWayToAnOlderOne(): void + { + $repository = self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-amd64.tar.gz', 'roadrunner-3.4.0-linux-amd64.deb'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz', 'roadrunner-3.2.0-linux-amd64.tar.gz'], + ]); + $config = DownloadConfig::fromSoftwareId('rr'); + $config->version = '3.*'; + + $result = $this->run([$repository], self::software(binary: true), $config, Architecture::ARM_64); + + Assert::same($result->version->string, 'v3.2.0'); + Assert::same($result->file->getFilename(), 'roadrunner-3.2.0-linux-arm64.tar.gz'); + } + + #[Test] + public function withoutABinaryThePackageIsStillChosen(): void + { + $repository = self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-amd64.tar.gz'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ]); + $config = DownloadConfig::fromSoftwareId('rr'); + $config->version = '3.*'; + + $result = $this->run([$repository], self::software(), $config, Architecture::ARM_64); + + Assert::same($result->version->string, 'v3.4.0'); + Assert::same($result->file->getFilename(), 'roadrunner-3.4.0-linux-arm64.deb'); + } + + #[Test] + public function skippedPackagesAreNamedInTheReport(): void + { + $repository = self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-amd64.deb', 'roadrunner-3.4.0-linux-arm64.rpm', 'roadrunner-3.4.0-linux-amd64.tar.gz'], + ]); + + try { + $this->run([$repository], self::software(binary: true), architecture: Architecture::ARM_64); + Assert::fail('DownloadFailed is expected when the release has only packages for the host.'); + } catch (DownloadFailed $e) { + Assert::string($e->report) + ->contains('v3.4.0: 4 asset(s), only OS packages match OS `linux`, architecture `arm64`') + ->contains('OS packages are skipped because a binary is expected: roadrunner-3.4.0-linux-arm64.deb, roadrunner-3.4.0-linux-arm64.rpm') + ->notContains('no asset matches'); + } + } + #[Test] public function versionConstraintSelectsMatchingRelease(): void { @@ -407,6 +457,32 @@ private static function software(bool $binary = false, int $repositories = 1): S return Software::fromArray($data); } + /** + * A repository with a release for each tag; the OS and architecture of an asset come from its name. + * + * @param array> $releases Asset names by release tag. + */ + private static function repoWithReleases(array $releases): RepositoryStub + { + $repository = new RepositoryStub('owner/repo'); + $list = []; + foreach ($releases as $tag => $names) { + $release = new ReleaseStub($repository, $tag, Version::fromVersionString($tag)); + $release->setAssets(\array_map( + static fn(string $name): ConfigurableAssetStub => new ConfigurableAssetStub( + $release, + $name, + OperatingSystem::tryFromBuildName($name), + Architecture::tryFromBuildName($name), + ), + $names, + )); + $list[] = $release; + } + + return new RepositoryStub('owner/repo', ReleasesCollection::create($list)); + } + /** * @param list|SequenceRepositoryFactoryStub $repositories * @param non-empty-string $uri Repository URI as written in the config. @@ -449,6 +525,7 @@ private function run( array|SequenceRepositoryFactoryStub $repositories, Software $software, ?DownloadConfig $config = null, + Architecture $architecture = Architecture::X86_64, ): DownloadResult { $factory = $repositories instanceof SequenceRepositoryFactoryStub ? $repositories @@ -457,7 +534,7 @@ private function run( $downloaderConfig = new DownloaderConfig(); $downloaderConfig->tmpDir = $this->tempDir; - $task = $this->makeDownloader($factory, $downloaderConfig)->download( + $task = $this->makeDownloader($factory, $downloaderConfig, $architecture)->download( $software, $config ?? DownloadConfig::fromSoftwareId('rr'), static fn(): null => null, @@ -467,18 +544,21 @@ private function run( return await(($task->handler)()); } - private function makeDownloader(SequenceRepositoryFactoryStub $factory, DownloaderConfig $config): Downloader - { + private function makeDownloader( + SequenceRepositoryFactoryStub $factory, + DownloaderConfig $config, + Architecture $architecture = Architecture::X86_64, + ): Downloader { return new Downloader( config: $config, logger: new Logger(), repositoryProvider: (new RepositoryProvider())->addRepositoryFactory($factory), - architecture: Architecture::tryFromString('amd64') ?? throw new \LogicException(), + architecture: $architecture, operatingSystem: OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, - assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, new LibcContainer(Libc::Gnu), new ArchiveFactory()), + assetSelector: new AssetSelector(OperatingSystem::Linux, $architecture, new LibcContainer(Libc::Gnu), new ArchiveFactory()), ); } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index c4149d2e..c2b22d5b 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -233,14 +233,6 @@ public function archivesAndPlainBinariesOutliveOsPackages(): void Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64']); } - #[Test] - public function aBinaryActionDropsOsPackagesToo(): void - { - $names = self::select(['tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz'], type: Type::Binary); - - Assert::same($names, ['tool-linux-amd64.tar.gz']); - } - #[Test] public function aWindowsExecutableOutlivesTheInstaller(): void { diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php index d4ac9b33..b1fca4d7 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php @@ -4,7 +4,7 @@ namespace Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Rule; -use Internal\DLoad\Module\Config\Schema\Action\Type; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Candidate; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\RuleRunner; use Testo\Assert; @@ -14,19 +14,22 @@ #[Covers(PackageRule::class)] final class PackageRuleTest { - private const ASSETS = [ - 'tool-linux-amd64', - 'tool-linux-amd64.tar.gz', + private const PACKAGES = [ 'tool-linux-amd64.deb', 'tool-linux-amd64.RPM', 'tool-linux-amd64.apk', + 'tool-linux-amd64.snap', + 'tool-linux-amd64.flatpak', 'tool-darwin-arm64.dmg', 'tool-darwin-arm64.pkg', 'tool-windows-amd64.msi', - 'tool-windows-amd64.exe', - 'tool-linux-amd64.AppImage', + 'tool-windows-amd64.msix', + 'tool-windows-amd64.msixbundle', + 'tool-windows-amd64.appx', + 'tool-windows-amd64.appxbundle', + 'tool.1.0.0.nupkg', ]; - private const WITHOUT_PACKAGES = [ + private const OTHERS = [ 'tool-linux-amd64', 'tool-linux-amd64.tar.gz', 'tool-windows-amd64.exe', @@ -36,24 +39,29 @@ final class PackageRuleTest #[Test] public function packagesAreRemovedWhenABinaryIsExpected(): void { - $selection = RuleRunner::run(new PackageRule(), self::ASSETS); + $selection = RuleRunner::run(new PackageRule(), [...self::OTHERS, ...self::PACKAGES]); - Assert::same(RuleRunner::names($selection), self::WITHOUT_PACKAGES); + Assert::same(RuleRunner::names($selection), self::OTHERS); } #[Test] - public function aBinaryActionRemovesPackagesToo(): void + public function removedPackagesAreKeptForTheReport(): void { - $selection = RuleRunner::run(new PackageRule(), self::ASSETS, type: Type::Binary); + $selection = RuleRunner::run(new PackageRule(), [...self::OTHERS, ...self::PACKAGES]); - Assert::same(RuleRunner::names($selection), self::WITHOUT_PACKAGES); + Assert::same(\array_keys($selection->removed), [PackageRule::KEY]); + Assert::same( + \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->removed[PackageRule::KEY]), + self::PACKAGES, + ); } #[Test] public function packagesAreKeptWhenNoBinaryIsExpected(): void { - $selection = RuleRunner::run(new PackageRule(), self::ASSETS, strict: false); + $selection = RuleRunner::run(new PackageRule(), [...self::OTHERS, ...self::PACKAGES], strict: false); - Assert::same(RuleRunner::names($selection), self::ASSETS); + Assert::same(RuleRunner::names($selection), [...self::OTHERS, ...self::PACKAGES]); + Assert::same($selection->removed, []); } } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php index 76dd730e..b0e758ad 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php @@ -31,6 +31,24 @@ public function removedCandidatesAreGone(): void Assert::same(self::names($selection), ['a', 'c']); } + #[Test] + public function candidatesRemovedWithAReasonAreKeptUnderIt(): void + { + $selection = self::selection('a', 'b', 'c', 'd') + ->remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'a') + ->remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'b', 'package') + ->rank('length', static fn(): int => 0) + ->remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'c', 'package') + ->remove(static fn(): bool => false, 'nothing'); + + Assert::same(self::names($selection), ['d']); + Assert::same(\array_keys($selection->removed), ['package']); + Assert::same( + \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->removed['package']), + ['b', 'c'], + ); + } + #[Test] public function anEarlierRankOutweighsALaterOne(): void { From 3acb91c75813917e4195836c06628113ca97a771 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 21:24:20 +0400 Subject: [PATCH 3/5] fix(downloader): warn when a release with only OS packages gives way to an older one docs: state that the package fallback reaches the 10 newest matching releases refactor(downloader): name the cap on tried releases test(downloader): cover the skipped OS packages line of the failure report The cap stays at 10: raising it to the API page size would load further release pages whenever the version constraint matches fewer releases. Assisted-By: Claude Opus 5.5 --- README-es.md | 2 +- README-ru.md | 2 +- README-zh.md | 2 +- README.md | 2 +- .../references/registry-entry.md | 2 +- .../references/troubleshooting.md | 2 +- src/Module/Downloader/Downloader.php | 25 +++++++++++++++--- .../Internal/AssetSelection/AssetSelector.php | 3 ++- .../Internal/Diagnostics/ReleaseAttempt.php | 14 +++++++--- .../Downloader/Internal/DownloadContext.php | 3 +++ .../Unit/Module/Downloader/DownloaderTest.php | 12 ++++++--- .../AssetSelection/AssetSelectorTest.php | 16 ------------ .../Diagnostics/DownloadDiagnosticsTest.php | 26 +++++++++++++++++++ 13 files changed, 79 insertions(+), 32 deletions(-) diff --git a/README-es.md b/README-es.md index 90e53e5f..98bb8a54 100644 --- a/README-es.md +++ b/README-es.md @@ -295,7 +295,7 @@ Cuando no se especifica `type`, DLoad automáticamente usa todos los manejadores > Usar `type="archive"` extraerá incluso archivos `.phar`. > [!NOTE] -> Cuando hay un `` configurado, con `type="binary"` o sin `type`, los paquetes del sistema operativo (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) nunca se seleccionan: DLoad no puede desempaquetarlos. Un release que solo ofrece un paquete para el host se omite, y DLoad toma el siguiente release más antiguo que siga cumpliendo la restricción de versión. +> Cuando hay un `` configurado, con `type="binary"` o sin `type`, los paquetes del sistema operativo (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) nunca se seleccionan: DLoad no puede desempaquetarlos. Un release que solo ofrece un paquete para el host se omite en favor de uno más antiguo, entre los 10 releases más recientes que cumplen la restricción de versión; fija `version` para ir más atrás. #### Extracción de Archivos (Preservando la Estructura) diff --git a/README-ru.md b/README-ru.md index 0c559a4e..0db71419 100644 --- a/README-ru.md +++ b/README-ru.md @@ -296,7 +296,7 @@ DLoad поддерживает три типа загрузки, которые > Использование `type="archive"` распакует даже `.phar` архивы. > [!NOTE] -> Если задан `` — с `type="binary"` или без `type`, — пакеты ОС (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) никогда не выбираются: DLoad не умеет их распаковывать. Релиз, в котором для хоста есть только пакет, пропускается, и DLoad берёт следующий более старый релиз, который по-прежнему удовлетворяет ограничению версии. +> Если задан `` — с `type="binary"` или без `type`, — пакеты ОС (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) никогда не выбираются: DLoad не умеет их распаковывать. Релиз, в котором для хоста есть только пакет, пропускается ради более старого — среди 10 новейших релизов, удовлетворяющих ограничению версии; чтобы уйти дальше в прошлое, закрепите `version`. #### Распаковка архива с сохранением структуры diff --git a/README-zh.md b/README-zh.md index da359423..c8071243 100644 --- a/README-zh.md +++ b/README-zh.md @@ -295,7 +295,7 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: > 使用 `type="archive"` 会解包甚至 `.phar` 压缩包。 > [!NOTE] -> 配置了 `` 时(无论是 `type="binary"` 还是未指定 `type`),永远不会选择操作系统安装包(`.deb`、`.rpm`、`.apk`、`.snap`、`.flatpak`、`.msi`、`.msix`、`.msixbundle`、`.appx`、`.appxbundle`、`.nupkg`、`.dmg`、`.pkg`):DLoad 无法解包它们。如果某个发布版本只为当前主机提供安装包,则会跳过它,改用下一个仍满足版本约束的更早版本。 +> 配置了 `` 时(无论是 `type="binary"` 还是未指定 `type`),永远不会选择操作系统安装包(`.deb`、`.rpm`、`.apk`、`.snap`、`.flatpak`、`.msi`、`.msix`、`.msixbundle`、`.appx`、`.appxbundle`、`.nupkg`、`.dmg`、`.pkg`):DLoad 无法解包它们。如果某个发布版本只为当前主机提供安装包,则会跳过它,改用更早的版本,范围限于满足版本约束的最新 10 个发布版本;要回溯更早的版本,请固定 `version`。 #### 解包压缩包(保留结构) diff --git a/README.md b/README.md index 848e656a..7c328f74 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ When `type` is not specified, DLoad automatically uses all available handlers: > Using `type="archive"` will unpack even `.phar` archives. > [!NOTE] -> When a `` is configured, with `type="binary"` or without a `type`, OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) are never selected: DLoad cannot unpack them. A release that offers only a package for the host is skipped, and DLoad takes the next older release that still satisfies the version constraint. +> When a `` is configured, with `type="binary"` or without a `type`, OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) are never selected: DLoad cannot unpack them. A release that offers only a package for the host is skipped for an older one, among the 10 newest releases that satisfy the version constraint; pin `version` to go further back. #### Archive Extraction (Preserving Structure) diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 4e5fd52b..9ca81dd7 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,7 +22,7 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. -Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower), and so are OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) everywhere: dload cannot unpack them, so a release that has only a package for the host gives way to the next older release that satisfies `version`. On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower), and so are OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) everywhere: dload cannot unpack them, so a release that has only a package for the host gives way to an older one among the 10 newest releases that satisfy `version` (pin `version` to go further back). On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 77271fae..5be68519 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -44,7 +44,7 @@ A candidate that matches neither is discarded. Common offenders: Compare with the host: `php -r "echo PHP_OS_FAMILY, ' / ', php_uname('m'), PHP_EOL;"`. -With a `binary`, OS packages for the host (`.deb`, `.msi`… — the list is in [`registry-entry.md`](registry-entry.md#asset-pattern)) are discarded too, since dload cannot unpack them, and a release offering only packages is skipped for the next older one. The report then says "only OS packages match" and lists them under "OS packages are skipped because a binary is expected"; when no older release fits, pin a `version` that ships an archive or a raw binary for the host. +With a `binary`, OS packages (`.deb`, `.msi`… — the list is in [`registry-entry.md`](registry-entry.md#asset-pattern)) are discarded too, since dload cannot unpack them, and a release offering only packages is skipped for an older one among the 10 newest releases that satisfy `version`, with a warning naming both versions. When the package names carry the host OS and arch, the report says "only OS packages match" and lists them under "OS packages are skipped because a binary is expected"; a package without an OS token (`tool.dmg`) is discarded by the OS check above instead. When no older release fits, pin a `version` that ships an archive or a raw binary for the host — pinning also reaches past the 10 newest. Several candidates left: dload tries them best first — host libc, then the fewest extra name tokens, then archives, then the release order. Run with `-vvv` to see the rank of every asset (`Asset ... ranked: os=0 arch=0 libc=0 extras=1 archive=0`); a wrong pick usually means the wanted build carries an extra token the others lack. diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 99022259..acdd8d61 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -65,6 +65,12 @@ final class Downloader /** Number of release names collected for the failure report. */ private const FETCHED_RELEASES_LIMIT = 10; + /** + * Number of the newest matching releases tried. A higher cap would load further release pages + * whenever the version constraint matches fewer releases than the cap. + */ + private const TRIED_RELEASES_LIMIT = 10; + public function __construct( private readonly DownloaderConfig $config, private readonly Logger $logger, @@ -201,7 +207,7 @@ private function processRepository(Repository $repository, DownloadContext $cont } /** @var ReleaseInterface[] $releases */ - $releases = $releasesCollection->limit(10)->sortByVersion()->toArray(); + $releases = $releasesCollection->limit(self::TRIED_RELEASES_LIMIT)->sortByVersion()->toArray(); $this->logger->debug('%d releases found.', \count($releases)); @@ -235,6 +241,17 @@ private function processRepository(Repository $repository, DownloadContext $cont try { await(coroutine($this->processRelease($context))); + + $skipped = $context->packagesOnlyRelease; + $skipped === null or $this->logger->warning( + '`%s` %s has only OS packages for `%s`/`%s`, downloaded %s instead.', + $context->software->getId(), + $skipped->getVersion()->string, + $this->operatingSystem->value, + $this->architecture->value, + $context->release->getVersion()->string, + ); + return $context->release; } catch (ReleaseGone $e) { // The registry must not offer this release again, and the list needs a fresh check @@ -301,16 +318,18 @@ private function processRelease(DownloadContext $context): \Closure ); $this->logger->debug('%d matching assets found.', \count($selection->candidates)); - $context->releaseAttempt->skippedPackages = \array_map( + $skippedPackages = \array_map( static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->removed[PackageRule::KEY] ?? [], ); + $context->releaseAttempt->registerSkippedPackages($skippedPackages); + $selection->isEmpty() && $skippedPackages !== [] and $context->packagesOnlyRelease ??= $context->release; $selection->isEmpty() and throw new NotFound( $strict ? \sprintf( '%s OS `%s`, architecture `%s`, name pattern `%s`%s', - $context->releaseAttempt->skippedPackages === [] ? 'no asset matches' : 'only OS packages match', + $skippedPackages === [] ? 'no asset matches' : 'only OS packages match', $this->operatingSystem->value, $this->architecture->value, $context->repoConfig->assetPattern, diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index acc31bfd..4acbaac6 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -55,7 +55,8 @@ public function __construct( new CompanionRule(), new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture, $operatingSystem), - // After the platform rules: the packages it removes are then the host's own, which the report names + // After the platform rules, so the report names only packages built for the host. A package whose + // name carries no OS token, like `tool.dmg`, is already gone then and stays out of the report. new PackageRule(), new LibcRule($container), new ExtrasRule(), diff --git a/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php b/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php index aaacef03..8ea5225b 100644 --- a/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php +++ b/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php @@ -20,12 +20,12 @@ final class ReleaseAttempt /** @var string|null Why the release was rejected */ public ?string $reason = null; - /** @var list Names of the OS packages for the host, skipped because a binary is expected */ - public array $skippedPackages = []; - /** @var list Names of all assets in the release */ private array $assetNames = []; + /** @var list Names of the OS packages for the host, skipped because a binary is expected */ + private array $skippedPackages = []; + /** @var list Errors occurred while downloading matched assets */ private array $failures = []; @@ -45,6 +45,14 @@ public function registerAssets(array $names): void $this->assetNames = $names; } + /** + * @param list $names Names of the OS packages for the host, skipped because a binary is expected + */ + public function registerSkippedPackages(array $names): void + { + $this->skippedPackages = $names; + } + /** * Registers a failure of a matched asset, e.g. a broken download. * diff --git a/src/Module/Downloader/Internal/DownloadContext.php b/src/Module/Downloader/Internal/DownloadContext.php index e224dcc0..1e6cd6c9 100644 --- a/src/Module/Downloader/Internal/DownloadContext.php +++ b/src/Module/Downloader/Internal/DownloadContext.php @@ -47,6 +47,9 @@ final class DownloadContext /** @var ReleaseAttempt Diagnostics of the release being processed */ public ReleaseAttempt $releaseAttempt; + /** @var ReleaseInterface|null Newest release skipped because it has only OS packages for the host */ + public ?ReleaseInterface $packagesOnlyRelease = null; + /** * Creates a new download context. * diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 3af09caf..a27d876c 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -34,6 +34,7 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; +use Symfony\Component\Console\Output\BufferedOutput; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -251,10 +252,13 @@ public function aReleaseWithOnlyAPackageForTheHostGivesWayToAnOlderOne(): void $config = DownloadConfig::fromSoftwareId('rr'); $config->version = '3.*'; - $result = $this->run([$repository], self::software(binary: true), $config, Architecture::ARM_64); + $output = new BufferedOutput(); + + $result = $this->run([$repository], self::software(binary: true), $config, Architecture::ARM_64, new Logger($output)); Assert::same($result->version->string, 'v3.2.0'); Assert::same($result->file->getFilename(), 'roadrunner-3.2.0-linux-arm64.tar.gz'); + Assert::string($output->fetch())->contains('`rr` v3.4.0 has only OS packages for `linux`/`arm64`, downloaded v3.2.0 instead.'); } #[Test] @@ -526,6 +530,7 @@ private function run( Software $software, ?DownloadConfig $config = null, Architecture $architecture = Architecture::X86_64, + Logger $logger = new Logger(), ): DownloadResult { $factory = $repositories instanceof SequenceRepositoryFactoryStub ? $repositories @@ -534,7 +539,7 @@ private function run( $downloaderConfig = new DownloaderConfig(); $downloaderConfig->tmpDir = $this->tempDir; - $task = $this->makeDownloader($factory, $downloaderConfig, $architecture)->download( + $task = $this->makeDownloader($factory, $downloaderConfig, $architecture, $logger)->download( $software, $config ?? DownloadConfig::fromSoftwareId('rr'), static fn(): null => null, @@ -548,10 +553,11 @@ private function makeDownloader( SequenceRepositoryFactoryStub $factory, DownloaderConfig $config, Architecture $architecture = Architecture::X86_64, + Logger $logger = new Logger(), ): Downloader { return new Downloader( config: $config, - logger: new Logger(), + logger: $logger, repositoryProvider: (new RepositoryProvider())->addRepositoryFactory($factory), architecture: $architecture, operatingSystem: OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index c2b22d5b..6264c4c8 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -233,22 +233,6 @@ public function archivesAndPlainBinariesOutliveOsPackages(): void Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64']); } - #[Test] - public function aWindowsExecutableOutlivesTheInstaller(): void - { - $names = self::select(['tool-windows-amd64.exe', 'tool-windows-amd64.msi'], os: OperatingSystem::Windows); - - Assert::same($names, ['tool-windows-amd64.exe']); - } - - #[Test] - public function osPackagesAreKeptWhenNoBinaryIsExpected(): void - { - $names = self::select(['tool-linux-amd64.deb'], strict: false); - - Assert::same($names, ['tool-linux-amd64.deb']); - } - /** * @param list $assets * @param non-empty-string $pattern diff --git a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php index df1e7d30..b6387dd0 100644 --- a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php +++ b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php @@ -84,6 +84,32 @@ public function reportListsCheckedReleasesWithTheirAssets(): void Assert::string($report)->contains('Failed asset `app-1.1.0-linux-amd64.tar.gz`: Broken archive'); } + #[Test] + public function reportNamesTheSkippedPackages(): void + { + $diagnostics = self::diagnostics(); + $repository = $diagnostics->addRepository('github', 'owner/repo', '/^app-.*/'); + $repository->matchedReleases = 1; + + $release = $repository->addRelease('v1.2.0'); + $release->registerAssets(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); + $release->registerSkippedPackages(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); + + Assert::same($release->describe()[2], ' OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm'); + Assert::string($diagnostics->render())->contains('OS packages are skipped because a binary is expected'); + } + + #[Test] + public function reportOmitsTheSkippedPackagesLineWhenNoneAreSkipped(): void + { + $diagnostics = self::diagnostics(); + $repository = $diagnostics->addRepository('github', 'owner/repo', '/^app-.*/'); + $repository->matchedReleases = 1; + $repository->addRelease('v1.2.0')->registerAssets(['app-1.2.0-darwin-arm64.tar.gz']); + + Assert::string($diagnostics->render())->notContains('OS packages are skipped'); + } + #[Test] public function reportContainsRepositoryLevelError(): void { From 5435281cf8c292ff8bc1e3bc80f38d9ad9e03c96 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 21:38:34 +0400 Subject: [PATCH 4/5] fix(downloader): forget a packages-only release when moving to another repository test(downloader): cover which skipped releases the packages-only warning names The release that had only OS packages was remembered for the whole download, so a later repository, or a refetched release list, warned that a version had only OS packages while downloading that same or a newer version from elsewhere. Assisted-By: Claude Opus 5.5 --- src/Module/Downloader/Downloader.php | 6 +- .../Unit/Module/Downloader/DownloaderTest.php | 82 ++++++++++++++++++- .../AssetSelection/AssetSelectorTest.php | 12 --- .../AssetSelection/Rule/PackageRuleTest.php | 7 -- .../Diagnostics/DownloadDiagnosticsTest.php | 4 +- 5 files changed, 87 insertions(+), 24 deletions(-) diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index acdd8d61..a1b36b52 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -66,8 +66,8 @@ final class Downloader private const FETCHED_RELEASES_LIMIT = 10; /** - * Number of the newest matching releases tried. A higher cap would load further release pages - * whenever the version constraint matches fewer releases than the cap. + * Number of the newest matching releases tried. Iteration stops after this many matches; a higher cap + * reads further release pages whenever the first pages hold fewer matches. */ private const TRIED_RELEASES_LIMIT = 10; @@ -185,6 +185,7 @@ public function download( private function processRepository(Repository $repository, DownloadContext $context, bool $mayRetry = true): \Closure { return function () use ($repository, $context, $mayRetry): ReleaseInterface { + $context->packagesOnlyRelease = null; // Set when a release turned out to be deleted: the release list is outdated then $forgotten = false; @@ -242,6 +243,7 @@ private function processRepository(Repository $repository, DownloadContext $cont try { await(coroutine($this->processRelease($context))); + /** @var ReleaseInterface|null $skipped */ $skipped = $context->packagesOnlyRelease; $skipped === null or $this->logger->warning( '`%s` %s has only OS packages for `%s`/`%s`, downloaded %s instead.', diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index a27d876c..9efa9204 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -19,6 +19,7 @@ use Internal\DLoad\Module\Downloader\Exception\DownloadFailed; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; +use Internal\DLoad\Module\Repository\Exception\AssetNotFoundException; use Internal\DLoad\Module\Repository\Exception\RateLimitException; use Internal\DLoad\Module\Repository\Repository; use Internal\DLoad\Module\Repository\RepositoryProvider; @@ -62,6 +63,60 @@ public static function provideGradualStrategies(): \Generator yield 'architecture only' => [null, Architecture::X86_64]; } + public static function providePackagesOnlyWarnings(): \Generator + { + yield 'the newest packages-only release is named' => [ + [self::repoWithReleases([ + 'v3.5.0' => ['roadrunner-3.5.0-linux-arm64.deb'], + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.rpm'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ])], + 'v3.2.0', + '`rr` v3.5.0 has only OS packages for `linux`/`arm64`, downloaded v3.2.0 instead.', + ]; + yield 'a failed binary is not a packages-only release' => [ + [self::repoWithReleases( + [ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-arm64.tar.gz'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ], + ['roadrunner-3.4.0-linux-arm64.tar.gz' => new \RuntimeException('broken')], + )], + 'v3.2.0', + null, + ]; + yield 'a release without packages is not a packages-only release' => [ + [self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-darwin-arm64.tar.gz'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ])], + 'v3.2.0', + null, + ]; + yield 'a packages-only release of another repository is forgotten' => [ + [ + self::repoWithReleases(['v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb']]), + self::repoWithReleases(['v3.4.0' => ['roadrunner-3.4.0-linux-arm64.tar.gz']]), + ], + 'v3.4.0', + null, + ]; + yield 'a packages-only release of an outdated list is forgotten' => [ + new SequenceRepositoryFactoryStub([ + self::repoWithReleases( + [ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb'], + 'v3.3.0' => ['roadrunner-3.3.0-linux-arm64.tar.gz'], + ], + ['roadrunner-3.3.0-linux-arm64.tar.gz' => new AssetNotFoundException('gone', 'owner/repo')], + ), + self::repoWithReleases(['v3.5.0' => ['roadrunner-3.5.0-linux-arm64.tar.gz']]), + ]), + 'v3.5.0', + null, + ]; + } + #[Test] public function deletedReleaseIsForgottenAndTheNextOneIsUsed(): void { @@ -261,6 +316,29 @@ public function aReleaseWithOnlyAPackageForTheHostGivesWayToAnOlderOne(): void Assert::string($output->fetch())->contains('`rr` v3.4.0 has only OS packages for `linux`/`arm64`, downloaded v3.2.0 instead.'); } + /** + * @param list|SequenceRepositoryFactoryStub $repositories + * @param non-empty-string $version Expected downloaded version. + * @param non-empty-string|null $warning Expected warning; `null` when none is expected. + */ + #[Test] + #[DataProvider('providePackagesOnlyWarnings')] + public function packagesOnlyWarningNamesOnlyAReleaseSkippedForItsPackages( + array|SequenceRepositoryFactoryStub $repositories, + string $version, + ?string $warning, + ): void { + $software = self::software(binary: true, repositories: \is_array($repositories) ? \count($repositories) : 1); + $output = new BufferedOutput(); + + $result = $this->run($repositories, $software, architecture: Architecture::ARM_64, logger: new Logger($output)); + + Assert::same($result->version->string, $version); + $warning === null + ? Assert::string($output->fetch())->notContains('has only OS packages') + : Assert::string($output->fetch())->contains($warning); + } + #[Test] public function withoutABinaryThePackageIsStillChosen(): void { @@ -465,8 +543,9 @@ private static function software(bool $binary = false, int $repositories = 1): S * A repository with a release for each tag; the OS and architecture of an asset come from its name. * * @param array> $releases Asset names by release tag. + * @param array $failures Download failures by asset name. */ - private static function repoWithReleases(array $releases): RepositoryStub + private static function repoWithReleases(array $releases, array $failures = []): RepositoryStub { $repository = new RepositoryStub('owner/repo'); $list = []; @@ -478,6 +557,7 @@ private static function repoWithReleases(array $releases): RepositoryStub $name, OperatingSystem::tryFromBuildName($name), Architecture::tryFromBuildName($name), + $failures[$name] ?? null, ), $names, )); diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 6264c4c8..beac7e58 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -213,18 +213,6 @@ public function archivesComeBeforeOtherFiles(): void Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64', 'tool-linux-amd64.deb']); } - /** - * An empty selection sends the downloader to the next release; a selected package would be - * downloaded and then fail the installation with nothing extracted. - */ - #[Test] - public function osPackagesAreDroppedWhenABinaryIsExpected(): void - { - $names = self::select(['tool-linux-amd64.deb', 'tool-linux-amd64.rpm', 'tool-linux-amd64.apk']); - - Assert::same($names, []); - } - #[Test] public function archivesAndPlainBinariesOutliveOsPackages(): void { diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php index b1fca4d7..8cb8e713 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php @@ -42,13 +42,6 @@ public function packagesAreRemovedWhenABinaryIsExpected(): void $selection = RuleRunner::run(new PackageRule(), [...self::OTHERS, ...self::PACKAGES]); Assert::same(RuleRunner::names($selection), self::OTHERS); - } - - #[Test] - public function removedPackagesAreKeptForTheReport(): void - { - $selection = RuleRunner::run(new PackageRule(), [...self::OTHERS, ...self::PACKAGES]); - Assert::same(\array_keys($selection->removed), [PackageRule::KEY]); Assert::same( \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->removed[PackageRule::KEY]), diff --git a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php index b6387dd0..e5e8769e 100644 --- a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php +++ b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php @@ -95,8 +95,8 @@ public function reportNamesTheSkippedPackages(): void $release->registerAssets(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); $release->registerSkippedPackages(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); - Assert::same($release->describe()[2], ' OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm'); - Assert::string($diagnostics->render())->contains('OS packages are skipped because a binary is expected'); + Assert::string($diagnostics->render()) + ->contains('OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm'); } #[Test] From ca6367098507328d1bbcefa8c6aaac92ee6a0b96 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 21:55:41 +0400 Subject: [PATCH 5/5] test(downloader): pin where the skipped packages line sits in a release report docs(downloader): note that the packages-only release is kept per repository pass Assisted-By: Claude Opus 5.5 --- src/Module/Downloader/Internal/DownloadContext.php | 6 +++++- .../Internal/Diagnostics/DownloadDiagnosticsTest.php | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Module/Downloader/Internal/DownloadContext.php b/src/Module/Downloader/Internal/DownloadContext.php index 1e6cd6c9..e10e23a5 100644 --- a/src/Module/Downloader/Internal/DownloadContext.php +++ b/src/Module/Downloader/Internal/DownloadContext.php @@ -47,7 +47,11 @@ final class DownloadContext /** @var ReleaseAttempt Diagnostics of the release being processed */ public ReleaseAttempt $releaseAttempt; - /** @var ReleaseInterface|null Newest release skipped because it has only OS packages for the host */ + /** + * @var ReleaseInterface|null Newest release of the current repository pass skipped because it has only + * OS packages for the host. Another repository or a refetched release list starts over, so a version + * found elsewhere is not reported against it. + */ public ?ReleaseInterface $packagesOnlyRelease = null; /** diff --git a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php index e5e8769e..ab0adc1b 100644 --- a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php +++ b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php @@ -94,7 +94,14 @@ public function reportNamesTheSkippedPackages(): void $release = $repository->addRelease('v1.2.0'); $release->registerAssets(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); $release->registerSkippedPackages(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); - + $release->addFailure('app-1.2.0-linux-amd64.tar.gz', new \RuntimeException('broken')); + + // Between the asset list and the failures, indented like them + Assert::same(\array_slice($release->describe(), 1), [ + ' Assets: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm', + ' OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm', + ' Failed asset `app-1.2.0-linux-amd64.tar.gz`: broken', + ]); Assert::string($diagnostics->render()) ->contains('OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm'); }