diff --git a/.gitignore b/.gitignore index e0bad38..d1094e6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ composer.lock .php-cs-fixer.cache .phpunit.result.cache /tests/fixtures/download +/tests/fixtures/var/ +.idea/ diff --git a/src/Model/TailwindBinaries.php b/src/Model/TailwindBinaries.php new file mode 100644 index 0000000..7d0cb7c --- /dev/null +++ b/src/Model/TailwindBinaries.php @@ -0,0 +1,50 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfonycasts\TailwindBundle\Model; + +class TailwindBinaries +{ + /** + * @param TailwindBinary[] $assets + */ + public function __construct( + private readonly string $name, + private readonly string $publishedAt, + private readonly array $assets, + ) { + } + + public function getAssetByBinaryName(string $assetName): ?TailwindBinary + { + foreach ($this->assets as $asset) { + if ($asset->getName() === $assetName) { + return $asset; + } + } + + return null; + } + + public function getName(): string + { + return $this->name; + } + + public function getPublishedAt(): string + { + return $this->publishedAt; + } + + /** @return TailwindBinary[] */ + public function getAssets(): array + { + return $this->assets; + } +} diff --git a/src/Model/TailwindBinary.php b/src/Model/TailwindBinary.php new file mode 100644 index 0000000..5e13592 --- /dev/null +++ b/src/Model/TailwindBinary.php @@ -0,0 +1,57 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfonycasts\TailwindBundle\Model; + +class TailwindBinary +{ + public function __construct( + private readonly string $name, + private readonly string $contentType, + private readonly int $size, + private readonly string $digest, + private readonly string $createdAt, + private readonly string $downloadUrl, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getSize(): int + { + return $this->size; + } + + public function getDigest(): string + { + if (empty($this->digest)) { + return ''; + } + + return explode(':', $this->digest, 2)[1]; + } + + public function getCreatedAt(): string + { + return $this->createdAt; + } + + public function getDownloadUrl(): string + { + return $this->downloadUrl; + } +} diff --git a/src/TailwindBinary.php b/src/TailwindBinary.php index 3ca2fc7..cced545 100644 --- a/src/TailwindBinary.php +++ b/src/TailwindBinary.php @@ -10,9 +10,12 @@ namespace Symfonycasts\TailwindBundle; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Process\Process; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfonycasts\TailwindBundle\Model\TailwindBinaries; +use Symfonycasts\TailwindBundle\Model\TailwindBinary as TailwindBinaryAsset; /** * Wraps and downloads the tailwindcss binary. @@ -96,19 +99,73 @@ private function getBinaryPath(): string return $this->binaryPath; } - $this->binaryPath = $this->binaryDownloadDir.'/'.$this->getVersion().'/'.self::getBinaryName($this->getRawVersion(), $this->binaryPlatform); + $this->binaryPath = Path::canonicalize( + $this->binaryDownloadDir.'/'.$this->getVersion().'/'.self::getBinaryName( + $this->getRawVersion(), + $this->binaryPlatform + ) + ); - if (!is_file($this->binaryPath)) { + if (!is_file($this->binaryPath) || 0 === filesize($this->binaryPath)) { + if (is_file($this->binaryPath)) { + unlink($this->binaryPath); + } $this->downloadExecutable(); } return $this->binaryPath; } + private function requestBinariesByVersion(string $version): TailwindBinaries + { + $url = \sprintf('https://api.github.com/repos/tailwindlabs/tailwindcss/releases/tags/v%s', $version); + + $response = $this->httpClient->request('GET', $url); + + $content = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + $assets = []; + foreach ($content['assets'] as $asset) { + if ('text/plain' === $asset['content_type']) { + continue; + } + + if (version_compare($version, '4.1.9', '<=')) { + $asset['digest'] = ''; + } + + $assets[] = new TailwindBinaryAsset( + name: $asset['name'], + contentType: $asset['content_type'], + size: $asset['size'], + digest: $asset['digest'], + createdAt: $asset['created_at'], + downloadUrl: $asset['browser_download_url'], + ); + } + + return new TailwindBinaries( + name: $content['name'], + publishedAt: $content['published_at'], + assets: $assets, + ); + } + private function downloadExecutable(): void { + $releases = $this->requestBinariesByVersion($this->getRawVersion()); $binaryName = self::getBinaryName($this->getRawVersion(), $this->binaryPlatform); - $url = \sprintf('https://github.com/tailwindlabs/tailwindcss/releases/download/%s/%s', $this->getVersion(), $binaryName); + + $releaseToDownload = $releases->getAssetByBinaryName($binaryName); + if (null === $releaseToDownload) { + $availableAssets = implode(', ', array_map( + static fn (TailwindBinaryAsset $a) => $a->getName(), + $releases->getAssets() + )); + throw new \RuntimeException(\sprintf('Could not find binary "%s" in the release. Available assets: %s', $binaryName, $availableAssets)); + } + + $url = $releaseToDownload->getDownloadUrl(); $this->output?->note(\sprintf('Downloading TailwindCSS binary from %s', $url)); @@ -120,28 +177,40 @@ private function downloadExecutable(): void $progressBar = null; $response = $this->httpClient->request('GET', $url, [ - 'on_progress' => function (int $dlNow, int $dlSize, array $info) use (&$progressBar): void { - // dlSize is not known at the start + 'on_progress' => function (int $dlNow, int $dlSize, array $info) use ( + $releaseToDownload, + &$progressBar + ): void { if (0 === $dlSize) { return; } if (!$progressBar) { - $progressBar = $this->output?->createProgressBar($dlSize); + $progressBar = $this->output?->createProgressBar($releaseToDownload->getSize()); } $progressBar?->setProgress($dlNow); }, ]); + $fileHandler = fopen($targetPath, 'w'); foreach ($this->httpClient->stream($response) as $chunk) { fwrite($fileHandler, $chunk->getContent()); } fclose($fileHandler); + $progressBar?->finish(); $this->output?->writeln(''); - // make file executable chmod($targetPath, 0777); + + $digest = $releaseToDownload->getDigest(); + if ('' !== $digest) { + $downloadedFileHash = hash_file('sha256', $targetPath); + if (!hash_equals($digest, $downloadedFileHash)) { + unlink($targetPath); + throw new \RuntimeException(\sprintf('Downloaded binary failed integrity check (expected hash: %s, actual hash: %s). The corrupt file has been removed. Please try again.', $digest, $downloadedFileHash)); + } + } } /** @@ -190,7 +259,7 @@ private static function getBinarySystem(string $version, string $platform): stri $arch = $architectures[$machine] ?? null; if (!$system || !$arch) { - throw new \Exception(\sprintf('Unknown platform or architecture (OS: %s, Machine: %s).', $os, $machine)); + throw new \UnexpectedValueException(\sprintf('Unknown platform or architecture (OS: %s, Machine: %s).', $os, $machine)); } // Detect MUSL only when version >= 4.0.0 diff --git a/tests/TailwindBinaryTest.php b/tests/TailwindBinaryTest.php index f5bda58..d585105 100644 --- a/tests/TailwindBinaryTest.php +++ b/tests/TailwindBinaryTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Process\Process; @@ -21,9 +22,10 @@ class TailwindBinaryTest extends TestCase /** * @dataProvider platformAndVersionProvider */ - public function testBinaryIsDownloadedAndProcessCreated(string $version, string $platform, string $expectedBinaryName) + public function testBinaryIsDownloadedAndProcessCreated(string $version, string $platform, string $expectedBinaryName): void { - $binaryDownloadDir = __DIR__.'/fixtures/download'; + $binaryDownloadDir = Path::canonicalize(__DIR__.'/fixtures/download'); + $fs = new Filesystem(); if (file_exists($binaryDownloadDir)) { $fs->remove($binaryDownloadDir); @@ -31,12 +33,13 @@ public function testBinaryIsDownloadedAndProcessCreated(string $version, string $fs->mkdir($binaryDownloadDir); $client = new MockHttpClient([ + new MockResponse((new Filesystem())->readFile(__DIR__.'/fixtures/MockGitHubResponse.json')), new MockResponse('fake binary contents'), ]); - $binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, 'fake-version', null, $client, $platform); + $binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, $version, null, $client, $platform); $process = $binary->createProcess(['-i', 'fake.css']); - $binaryFile = $binaryDownloadDir.'/fake-version/'.$expectedBinaryName; + $binaryFile = $binaryDownloadDir.'/'.$version.'/'.$expectedBinaryName; $this->assertFileExists($binaryFile); $this->assertSame( @@ -48,7 +51,7 @@ public function testBinaryIsDownloadedAndProcessCreated(string $version, string /** * @dataProvider versionProvider */ - public function testGetVersionFromBinary(string $version) + public function testGetVersionFromBinary(string $version): void { $binaryDownloadDir = __DIR__.'/fixtures/download'; $fs = new Filesystem(); @@ -56,7 +59,7 @@ public function testGetVersionFromBinary(string $version) $fs->remove($binaryDownloadDir); } $fs->mkdir($binaryDownloadDir); - $binaryFile = $binaryDownloadDir.'/'.$version.'/'.TailwindBinary::getBinaryName(ltrim($version, 'v')); + $binaryFile = Path::canonicalize($binaryDownloadDir.'/'.$version.'/'.TailwindBinary::getBinaryName(ltrim($version, 'v'))); $binary1 = new TailwindBinary($binaryDownloadDir, __DIR__, null, $version); @@ -76,7 +79,7 @@ public static function versionProvider(): iterable yield ['v4.0.7']; } - public function testCustomBinaryUsed() + public function testCustomBinaryUsed(): void { $client = new MockHttpClient(); @@ -90,6 +93,62 @@ public function testCustomBinaryUsed() ); } + public function testZeroByteFileIsReplacedOnRedownload(): void + { + $binaryDownloadDir = Path::canonicalize(__DIR__.'/fixtures/download'); + + $fs = new Filesystem(); + if (file_exists($binaryDownloadDir)) { + $fs->remove($binaryDownloadDir); + } + $fs->mkdir($binaryDownloadDir.'/v4.0.0'); + + // Place a 0-byte file to simulate a corrupted/interrupted download + // Use v4.0.0 (<=4.1.9) so no digest is available and the integrity check is skipped + $binaryName = 'tailwindcss-linux-x64'; + $corruptFile = $binaryDownloadDir.'/v4.0.0/'.$binaryName; + file_put_contents($corruptFile, ''); + $this->assertSame(0, filesize($corruptFile)); + + $client = new MockHttpClient([ + new MockResponse((new Filesystem())->readFile(__DIR__.'/fixtures/MockGitHubResponse.json')), + new MockResponse('fake binary contents'), + ]); + + $binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, 'v4.0.0', null, $client, 'linux-x64'); + $binary->createProcess(['-i', 'fake.css']); + + $this->assertFileExists($corruptFile); + $this->assertGreaterThan(0, filesize($corruptFile)); + } + + public function testIntegrityFailureDeletesFileAndThrows(): void + { + $binaryDownloadDir = Path::canonicalize(__DIR__.'/fixtures/download'); + + $fs = new Filesystem(); + if (file_exists($binaryDownloadDir)) { + $fs->remove($binaryDownloadDir); + } + $fs->mkdir($binaryDownloadDir); + + // Return valid API response (with a real digest) but wrong binary content so hash won't match + $client = new MockHttpClient([ + new MockResponse((new Filesystem())->readFile(__DIR__.'/fixtures/MockGitHubResponse.json')), + new MockResponse('this content does not match the sha256 in the fixture'), + ]); + + $binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, 'v4.1.16', null, $client, 'linux-x64'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/integrity check/'); + $binary->createProcess(['-i', 'fake.css']); + + // The corrupt file must have been removed + $binaryFile = $binaryDownloadDir.'/v4.1.16/tailwindcss-linux-x64'; + $this->assertFileDoesNotExist($binaryFile); + } + public function platformAndVersionProvider(): iterable { yield ['3.4.17', 'linux-arm64', 'tailwindcss-linux-arm64']; diff --git a/tests/TailwindBuilderTest.php b/tests/TailwindBuilderTest.php index 92eea42..ee433b5 100644 --- a/tests/TailwindBuilderTest.php +++ b/tests/TailwindBuilderTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; use Symfonycasts\TailwindBundle\TailwindBuilder; class TailwindBuilderTest extends TestCase @@ -19,7 +20,7 @@ class TailwindBuilderTest extends TestCase protected function setUp(): void { $fs = new Filesystem(); - $fs->mkdir(__DIR__.'/fixtures/var/tailwind'); + $fs->mkdir(Path::canonicalize(__DIR__.'/fixtures/var/tailwind')); } protected function tearDown(): void @@ -30,7 +31,7 @@ protected function tearDown(): void // so try to clean up the dir a few times while (true) { try { - $fs->remove(__DIR__.'/fixtures/var/tailwind'); + $fs->remove(Path::canonicalize(__DIR__.'/fixtures/var/tailwind')); break; } catch (IOException $e) { if ($i++ > 5) { diff --git a/tests/fixtures/MockGitHubResponse.json b/tests/fixtures/MockGitHubResponse.json new file mode 100644 index 0000000..975fc69 --- /dev/null +++ b/tests/fixtures/MockGitHubResponse.json @@ -0,0 +1,379 @@ +{ + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/256642784", + "assets_url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/256642784/assets", + "upload_url": "https://uploads.github.com/repos/tailwindlabs/tailwindcss/releases/256642784/assets{?name,label}", + "html_url": "https://github.com/tailwindlabs/tailwindcss/releases/tag/v4.1.16", + "id": 256642784, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOBlGyP84PTA7g", + "tag_name": "v4.1.16", + "target_commitish": "main", + "name": "v4.1.16", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2025-10-23T10:32:27Z", + "updated_at": "2025-10-23T10:55:20Z", + "published_at": "2025-10-23T10:55:20Z", + "assets": [ + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718831", + "id": 307718831, + "node_id": "RA_kwDOBlGyP84SV2qv", + "name": "sha256sums.txt", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "text/plain", + "state": "uploaded", + "size": 652, + "digest": "sha256:962687e148bb03a6f0927b80b0824f78bb876b7b6c2584cef19369871d678b60", + "download_count": 167, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:19Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/sha256sums.txt" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718831", + "id": 307718831, + "node_id": "RA_kwDOBlGyP84SV2qv", + "name": "tailwindcss-linux-armv7", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 98765432, + "digest": "sha256:aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "download_count": 12, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:25Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-armv7" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718832", + "id": 307718832, + "node_id": "RA_kwDOBlGyP84SV2qw", + "name": "tailwindcss-linux-arm64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 113107514, + "digest": "sha256:967eb434f4d6a1c0dfda106decc646cb742e04d745aa484726023cdd46ba8eda", + "download_count": 302, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:25Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-arm64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718833", + "id": 307718833, + "node_id": "RA_kwDOBlGyP84SV2qx", + "name": "tailwindcss-linux-arm64-musl", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 108432995, + "digest": "sha256:bd1947c4ee8126333db2ae87fd19232aaca9286f930e4d50fa5e0c6d56c16036", + "download_count": 190, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:24Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-arm64-musl" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718836", + "id": 307718836, + "node_id": "RA_kwDOBlGyP84SV2q0", + "name": "tailwindcss-linux-x64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 122445527, + "digest": "sha256:09e6876a63ceb09ccd7e5867e3dbb2b2dc65c3a2f2e2fe210d68ea3bc0432050", + "download_count": 5284, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:26Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-x64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718837", + "id": 307718837, + "node_id": "RA_kwDOBlGyP84SV2q1", + "name": "tailwindcss-linux-x64-musl", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 115048104, + "digest": "sha256:02ffe87b7e37da4c661156862743fd65e71ec379f32fccf9588faf33d8cb12cb", + "download_count": 735, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:25Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-x64-musl" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718834", + "id": 307718834, + "node_id": "RA_kwDOBlGyP84SV2qy", + "name": "tailwindcss-macos-arm64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 76693168, + "digest": "sha256:e6cd44b8167f5746ca32e54f6a1411dd1a6c0dd15d26a9c273b3b2ed9d87df7d", + "download_count": 787, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:23Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-macos-arm64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718835", + "id": 307718835, + "node_id": "RA_kwDOBlGyP84SV2qz", + "name": "tailwindcss-macos-x64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 81069184, + "digest": "sha256:fde2aed09bf249cab9f986fd6f2089e3ce9a9ce1c7862fddbfa807c417e0f5d3", + "download_count": 279, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:23Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-macos-x64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718838", + "id": 307718838, + "node_id": "RA_kwDOBlGyP84SV2q2", + "name": "tailwindcss-windows-x64.exe", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-msdos-program", + "state": "uploaded", + "size": 133820928, + "digest": "sha256:07d351195199d9b3334cfdf7bbce39dd91abc2fb319041c78b7405bc3f850daf", + "download_count": 1969, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:27Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-windows-x64.exe" + } + ], + "tarball_url": "https://api.github.com/repos/tailwindlabs/tailwindcss/tarball/v4.1.16", + "zipball_url": "https://api.github.com/repos/tailwindlabs/tailwindcss/zipball/v4.1.16", + "body": "### Fixed\r\n\r\n- Discard candidates with an empty data type ([#19172](https://github.com/tailwindlabs/tailwindcss/pull/19172))\r\n- Fix canonicalization of arbitrary variants with attribute selectors ([#19176](https://github.com/tailwindlabs/tailwindcss/pull/19176))\r\n- Fix invalid colors due to nested `&` ([#19184](https://github.com/tailwindlabs/tailwindcss/pull/19184))\r\n- Improve canonicalization for `& > :pseudo` and `& :pseudo` arbitrary variants ([#19178](https://github.com/tailwindlabs/tailwindcss/pull/19178))\r\n", + "reactions": { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/256642784/reactions", + "total_count": 44, + "+1": 19, + "-1": 0, + "laugh": 0, + "hooray": 6, + "confused": 0, + "heart": 12, + "rocket": 5, + "eyes": 2 + } +} \ No newline at end of file