Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ php artisan og:generate --type=compare --locale=all # types: site|blog|database
# commit public/og; delete a retired page's card (Seo/OgCardsTest fails on a missing or orphaned card)
php artisan assets:generate # after editing resources/data/assets.json
php artisan assets:generate --check # checks only frontend asset catalogs
php artisan release:check # network: compares platforms.json with GitHub, Sparkle, Homebrew, App Store
php artisan release:check # network: compares platforms.json with GitHub, Sparkle, Homebrew, App Store, and Linux's PPA, COPR and Flatpak once it ships
```

Run `vendor/bin/pint --dirty --format agent` after editing any PHP file.
Expand Down
34 changes: 34 additions & 0 deletions app/Console/Commands/RefreshLinuxReleaseCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Console\Commands;

use App\Services\Releases\LinuxReleaseService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

#[Signature('release:refresh-linux')]
#[Description('Fetch the current Linux release from GitHub for the download page')]
class RefreshLinuxReleaseCommand extends Command
{
public function handle(LinuxReleaseService $releases): int
{
$release = $releases->refresh();

if ($release !== null) {
$this->info("Linux release {$release->version} ({$release->source}).");

return self::SUCCESS;
}

if ($releases->failing()) {
$this->warn('GitHub has no Linux release on this channel yet, or did not answer. Serving the last good copy, or platforms.json.');

return self::FAILURE;
}

$this->line('Another refresh is running. Nothing to do.');

return self::SUCCESS;
}
}
104 changes: 102 additions & 2 deletions app/Console/Commands/ReleaseCheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Console\Commands;

use App\Services\Releases\LinuxReleaseService;
use App\Services\Releases\MacReleaseService;
use App\Services\Releases\PlatformCatalog;
use App\Services\Releases\SparkleAppcast;
Expand Down Expand Up @@ -32,7 +33,7 @@
* source cannot be read, because an unchecked fact is not a checked one.
*/
#[Signature('release:check')]
#[Description('Compare GitHub, the Sparkle appcast, Homebrew and the App Store with resources/data/platforms.json.')]
#[Description('Compare GitHub, the Sparkle appcast, Homebrew, the App Store and, once Linux ships, its PPA, COPR and Flatpak repository with resources/data/platforms.json.')]
class ReleaseCheckCommand extends Command
{
private const TIMEOUT_SECONDS = 10;
Expand All @@ -48,7 +49,7 @@ class ReleaseCheckCommand extends Command
*/
private array $rows = [];

public function handle(PlatformCatalog $platforms, MacReleaseService $releases, SparkleAppcast $appcast): int
public function handle(PlatformCatalog $platforms, MacReleaseService $releases, SparkleAppcast $appcast, LinuxReleaseService $linuxReleases): int
{
$mac = $platforms->find('mac');
$ios = $platforms->find('ios');
Expand Down Expand Up @@ -96,6 +97,11 @@ public function handle(PlatformCatalog $platforms, MacReleaseService $releases,
is_array($de) ? ((int) ($de['resultCount'] ?? 0) > 0 ? 'yes' : 'no') : null,
);

// Before Linux ships no channel holds a package, so its rows would only report what is not there yet.
if ($platforms->isReleased('linux')) {
$this->checkLinux($platforms, $linuxReleases);
}

$this->table(['Channel', 'Field', 'platforms.json', 'Live', 'Result'], $this->rows);

$failures = array_filter($this->rows, fn(array $row): bool => in_array($row[4], [self::DRIFT, self::UNREADABLE], true));
Expand All @@ -118,6 +124,100 @@ public function handle(PlatformCatalog $platforms, MacReleaseService $releases,
* trailing GitHub is expected, and what matters is that `floorVersion`
* says so, which the floor row checks.
*/
private function checkLinux(PlatformCatalog $platforms, LinuxReleaseService $releases): void
{
$linux = $platforms->find('linux') ?? [];
$expected = $this->string($linux['release']['version'] ?? null);

$github = $releases->fromGitHub();
$this->compare('GitHub linux-v releases', 'version', $expected, $github?->version);
$this->compare('GitHub linux-v releases', 'published', $this->string($linux['release']['publishedAt'] ?? null), $github?->publishedAt);

$ppa = $platforms->destination('linux', 'ppa');
$this->compare('Launchpad PPA', 'version', $expected, is_array($ppa) ? $this->ppaVersion((string) $ppa['repository']) : null);

$copr = $platforms->destination('linux', 'copr');
$this->compare('COPR', 'version', $expected, is_array($copr) ? $this->coprVersion((string) $copr['repository']) : null);

$flatpak = $platforms->destination('linux', 'flatpak');
$this->compare('Flatpak repository', 'flatpakref reachable', 'yes', is_array($flatpak) ? $this->reachable((string) $flatpak['url']) : null);
}

// `ppa:tablepro/tablepro-beta` publishes `0.1.0~beta.1-0ppa1~26.04`; the upstream part is the release.
private function ppaVersion(string $repository): ?string
{
if (preg_match('#^ppa:([a-z0-9.+-]+)/([a-z0-9.+-]+)$#', $repository, $matches) !== 1) {
return null;
}

try {
$response = Http::timeout(self::TIMEOUT_SECONDS)->get("https://api.launchpad.net/1.0/~{$matches[1]}/+archive/ubuntu/{$matches[2]}", [
'ws.op' => 'getPublishedSources',
'source_name' => 'tablepro',
'status' => 'Published',
]);
} catch (\Throwable) {
return null;
}

$versions = array_filter(array_map(fn(mixed $entry): ?string => is_array($entry) ? $this->upstream($entry['source_package_version'] ?? null) : null, $response->json('entries') ?? []));

return $response->ok() ? $this->highest($versions) : null;
}

// `@tablepro/tablepro-beta` builds `0.1.0~beta.1-1`; the upstream part is the release.
private function coprVersion(string $repository): ?string
{
if (preg_match('#^(@?[a-z0-9_.-]+)/([a-z0-9_.-]+)$#', $repository, $matches) !== 1) {
return null;
}

try {
$response = Http::timeout(self::TIMEOUT_SECONDS)->get('https://copr.fedorainfracloud.org/api_3/package', [
'ownername' => $matches[1],
'projectname' => $matches[2],
'packagename' => 'tablepro',
'with_latest_succeeded_build' => 'True',
]);
} catch (\Throwable) {
return null;
}

return $response->ok() ? $this->upstream($response->json('builds.latest_succeeded.source_package.version')) : null;
}

private function reachable(string $url): ?string
{
try {
return Http::timeout(self::TIMEOUT_SECONDS)->head($url)->successful() ? 'yes' : 'no';
} catch (\Throwable) {
return null;
}
}

private function upstream(mixed $packageVersion): ?string
{
if (! is_string($packageVersion) || preg_match('/^(?:\d+:)?(\d+\.\d+\.\d+(?:~beta\.\d+)?)/', $packageVersion, $matches) !== 1) {
return null;
}

return str_replace('~', '-', $matches[1]);
}

/**
* @param array<int, string> $versions
*/
private function highest(array $versions): ?string
{
if ($versions === []) {
return null;
}

usort($versions, fn(string $a, string $b): int => version_compare($b, $a));

return $versions[0];
}

private function compare(string $channel, string $field, ?string $expected, ?string $live, bool $drift = true, bool $readable = true): void
{
if (! $readable || $live === null) {
Expand Down
90 changes: 90 additions & 0 deletions app/Services/Releases/LinuxRelease.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Services\Releases;

use Illuminate\Support\Carbon;

final readonly class LinuxRelease
{
public const SOURCE_GITHUB = 'github';

public const SOURCE_DATA = 'data';

/**
* @param list<array{name: string, url: string, bytes: int|null, sha256: string|null}> $assets
*/
public function __construct(
public string $version,
public ?string $publishedAt,
public bool $prerelease,
public array $assets,
public ?string $checksumsUrl,
public string $releaseUrl,
public string $releasesUrl,
public string $source,
) {}

/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
if (! is_string($data['version'] ?? null) || ! is_string($data['releaseUrl'] ?? null) || ! is_string($data['releasesUrl'] ?? null) || ! is_string($data['source'] ?? null)) {
return null;
}

$assets = [];

foreach (is_array($data['assets'] ?? null) ? $data['assets'] : [] as $asset) {
if (is_array($asset) && is_string($asset['name'] ?? null) && is_string($asset['url'] ?? null)) {
$assets[] = [
'name' => $asset['name'],
'url' => $asset['url'],
'bytes' => is_int($asset['bytes'] ?? null) ? $asset['bytes'] : null,
'sha256' => is_string($asset['sha256'] ?? null) ? $asset['sha256'] : null,
];
}
}

return new self(
version: $data['version'],
publishedAt: is_string($data['publishedAt'] ?? null) ? $data['publishedAt'] : null,
prerelease: ($data['prerelease'] ?? false) === true,
assets: $assets,
checksumsUrl: is_string($data['checksumsUrl'] ?? null) ? $data['checksumsUrl'] : null,
releaseUrl: $data['releaseUrl'],
releasesUrl: $data['releasesUrl'],
source: $data['source'],
);
}

/**
* @return array{version: string, publishedAt: string|null, prerelease: bool, assets: list<array{name: string, url: string, bytes: int|null, sha256: string|null}>, checksumsUrl: string|null, releaseUrl: string, releasesUrl: string, source: string}
*/
public function toArray(): array
{
return [
'version' => $this->version,
'publishedAt' => $this->publishedAt,
'prerelease' => $this->prerelease,
'assets' => $this->assets,
'checksumsUrl' => $this->checksumsUrl,
'releaseUrl' => $this->releaseUrl,
'releasesUrl' => $this->releasesUrl,
'source' => $this->source,
];
}

/**
* The date is written in the page's language here, so the server render and the browser agree.
*
* @return array<string, mixed>
*/
public function toProps(string $locale): array
{
return [
...$this->toArray(),
'publishedOn' => $this->publishedAt === null ? null : Carbon::parse($this->publishedAt)->locale($locale)->isoFormat('LL'),
];
}
}
Loading