Skip to content

Commit

Permalink
Update self-update to support an optional version constraint
Browse files Browse the repository at this point in the history
It will limit the release to the highest matching candidate based on a min stability default to stable
  • Loading branch information
andytson-inviqa committed Feb 27, 2023
1 parent 519899c commit 5e82a0d
Show file tree
Hide file tree
Showing 4 changed files with 335 additions and 92 deletions.
42 changes: 23 additions & 19 deletions src/Updater/Plugin/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use my127\Console\Application\Application;
use my127\Console\Application\Plugin\Plugin;
use my127\Console\Usage\Input;
use my127\Workspace\Application as BaseApplication;
use my127\Workspace\Updater\Exception\NoUpdateAvailableException;
use my127\Workspace\Updater\Exception\NoVersionDeterminedException;
Expand All @@ -23,30 +24,33 @@ public function setup(Application $application): void
{
$application->section('self-update')
->description('Updates the current version of workspace.')
->action($this->action());
->usage('self-update [<version-constraint>]')
->action(fn (Input $input) => $this->action($input));
}

private function action()
private function action(Input $input)
{
return function () {
$pharPath = \Phar::running(false);
if (empty($pharPath)) {
echo 'This command can only be executed from within the ws utility.' . PHP_EOL;
exit(1);
}
$pharPath = \Phar::running(false);
if (empty($pharPath)) {
echo 'This command can only be executed from within the ws utility.' . PHP_EOL;
exit(1);
}

try {
try {
if ($input->getArgument('version-constraint')) {
$this->updater->update(BaseApplication::getVersion(), $input->getArgument('version-constraint'), $pharPath);
} else {
$this->updater->updateLatest(BaseApplication::getVersion(), $pharPath);
} catch (NoUpdateAvailableException $e) {
echo sprintf('You are already running the latest version of workspace: %s', $e->getCurrentVersion()) . PHP_EOL;
exit(1);
} catch (NoVersionDeterminedException $e) {
echo 'Unable to determine your current workspace version. You are likely not using a tagged released.' . PHP_EOL;
exit(1);
} catch (\RuntimeException $e) {
echo sprintf('%s. Aborting self-update', $e->getMessage()) . PHP_EOL;
exit(1);
}
};
} catch (NoUpdateAvailableException $e) {
echo sprintf('You are already running the latest version of workspace: %s', $e->getCurrentVersion()) . PHP_EOL;
exit(1);
} catch (NoVersionDeterminedException $e) {
echo 'Unable to determine your current workspace version. You are likely not using a tagged released.' . PHP_EOL;
exit(1);
} catch (\RuntimeException $e) {
echo sprintf('%s. Aborting self-update', $e->getMessage()) . PHP_EOL;
exit(1);
}
}
}
80 changes: 80 additions & 0 deletions src/Updater/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace my127\Workspace\Updater;

use Composer\Semver\Semver;
use Composer\Semver\VersionParser;
use my127\Workspace\Updater\Exception\NoUpdateAvailableException;
use my127\Workspace\Updater\Exception\NoVersionDeterminedException;

Expand All @@ -11,6 +13,21 @@ class Updater
public const CODE_NO_RELEASES = 101;
public const CODE_ERR_FETCHING_NEXT_RELEASE = 102;

public const STABILITY_STABLE = 0;
public const STABILITY_RC = 5;
public const STABILITY_BETA = 10;
public const STABILITY_ALPHA = 15;
public const STABILITY_DEV = 20;

/** @var array<string, self::STABILITY_*> */
public static $stabilities = [
'stable' => self::STABILITY_STABLE,
'RC' => self::STABILITY_RC,
'beta' => self::STABILITY_BETA,
'alpha' => self::STABILITY_ALPHA,
'dev' => self::STABILITY_DEV,
];

/** @var string */
private $apiUrl;

Expand All @@ -32,6 +49,15 @@ public function updateLatest(string $currentVersion, string $targetPath)
$this->doUpdate($currentVersion, $latest, $targetPath);
}

public function update(string $currentVersion, string $targetConstraint, string $targetPath)
{
$latest = $this->getLatestReleaseByConstraint($targetConstraint);
if ($latest->getVersion() == $currentVersion) {
throw new NoUpdateAvailableException($currentVersion);
}
$this->doUpdate($currentVersion, $latest, $targetPath);
}

private function doUpdate(string $currentVersion, Release $release, string $targetPath)
{
if (empty($currentVersion)) {
Expand Down Expand Up @@ -81,6 +107,60 @@ private function getLatestRelease(): Release
return new Release($latest->assets[0]->browser_download_url, $latest->tag_name);
}

private function getLatestReleaseByConstraint(string $targetConstraint): Release
{
try {
$releasesRaw = file_get_contents($this->apiUrl, false, $this->createStreamContext());
} catch (\Throwable $e) {
throw new \RuntimeException('Error fetching latest release from GitHub.', self::CODE_ERR_FETCHING_RELEASES);
}

$versionParser = new VersionParser();

$parts = explode('@', $targetConstraint);
$constraint = $parts[0];
if (count($parts) > 1) {
$minStability = VersionParser::normalizeStability($parts[1]);
} else {
$minStability = $versionParser->parseStability($constraint);
}

$releases = json_decode($releasesRaw);
$sortedVersions = Semver::rsort(array_map(fn ($release) => $release->tag_name, $releases));
$filteredVersions = Semver::satisfiedBy($sortedVersions, $constraint);
$filteredStabilityVersions = $this->filterVersionsByMinStability($versionParser, $filteredVersions, $minStability);
$filteredReleases = $this->filterReleasesByVersions($releases, $filteredStabilityVersions);

if (count($filteredReleases) == 0) {
throw new \RuntimeException(sprintf('No releases match the version constraint "%s".', $targetConstraint), self::CODE_ERR_FETCHING_RELEASES);
}

$latest = $filteredReleases[0];

return new Release($latest->assets[0]->browser_download_url, $latest->tag_name);
}

/**
* @param string[] $versions
*
* @return string[]
*/
private function filterVersionsByMinStability(VersionParser $versionParser, array $versions, string $minStability): array
{
return array_filter($versions, fn ($version) => self::$stabilities[$versionParser->parseStability($version)] <= self::$stabilities[$minStability]);
}

/**
* @param object[] $releases
* @param string[] $versions
*
* @return object[]
*/
private function filterReleasesByVersions(array $releases, array $versions): array
{
return array_values(array_filter($releases, fn ($release) => in_array($release->tag_name, $versions)));
}

/**
* @return resource
*/
Expand Down
83 changes: 83 additions & 0 deletions tests/Test/Updater/UpdaterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ public static function tearDownAfterClass(): void
}
}

protected function tearDown(): void
{
if (file_exists(__DIR__ . '/fixtures/generated/fake.phar')) {
\Phar::unlinkArchive(__DIR__ . '/fixtures/generated/fake.phar');
}
if (file_exists(__DIR__ . '/fixtures/generated/fake.phar.tar')) {
\Phar::unlinkArchive(__DIR__ . '/fixtures/generated/fake.phar.tar');
}
}

public static function setUpBeforeClass(): void
{
if (!is_dir(__DIR__ . '/fixtures/generated')) {
Expand Down Expand Up @@ -95,6 +105,79 @@ public function downloadsLatestReleaseToDesiredTargetPath()
$this->assertFileExists($temp);
}

/** @test */
public function downloadsLatestCandidateOfConstraintToDesiredTargetPath()
{
$this->prepareFakePhar();
$this->prepareReleasesFixture('valid.json', __DIR__ . '/fixtures/generated/fake.phar', '1.0.0', '0.9.1');

$temp = sys_get_temp_dir() . '/test-ws-download';
$updater = new Updater(__DIR__ . '/fixtures/generated/valid.json', new NullOutput());
$updater->update('0.9.0', '~0.9.1', $temp);

$this->assertFileExists($temp);
}

/** @test */
public function downloadsLatestStableCandidateOfConstraintToDesiredTargetPath()
{
$this->prepareFakePhar();
$this->prepareReleasesFixture('valid.json', __DIR__ . '/fixtures/generated/fake.phar', '1.0.0-alpha1', '0.9.1');

$temp = sys_get_temp_dir() . '/test-ws-download';
$updater = new Updater(__DIR__ . '/fixtures/generated/valid.json', new NullOutput());
$updater->update('0.9.0', '~0.9.1', $temp);

$this->assertFileExists($temp);
}

/** @test */
public function exceptionThrownWhenNoStableCandidate()
{
$this->prepareFakePhar();
$this->prepareReleasesFixture('valid.json', __DIR__ . '/fixtures/generated/fake.phar', '1.0.0-alpha1', '0.9.1-alpha1');
$this->expectException(\RuntimeException::class);
$this->expectExceptionCode(Updater::CODE_ERR_FETCHING_RELEASES);

$temp = sys_get_temp_dir() . '/test-ws-download';
$updater = new Updater(__DIR__ . '/fixtures/generated/valid.json', new NullOutput());
$updater->update('0.9.0', '~0.9.1', $temp);
}

/** @test */
public function exceptionThrownWhenNoRCCandidate()
{
$this->prepareFakePhar();
$this->prepareReleasesFixture('valid.json', __DIR__ . '/fixtures/generated/fake.phar', '1.0.0-alpha1', '0.9.1-alpha1');
$this->expectException(\RuntimeException::class);
$this->expectExceptionCode(Updater::CODE_ERR_FETCHING_RELEASES);

$temp = sys_get_temp_dir() . '/test-ws-download';
$updater = new Updater(__DIR__ . '/fixtures/generated/valid.json', new NullOutput());
$updater->update('0.9.0', '~0.9.1@RC', $temp);
}

/** @test */
public function downloadsLatestAlphaCandidateOfConstraintToDesiredTargetPath()
{
$this->prepareFakePhar();
$this->prepareReleasesFixture('valid.json', __DIR__ . '/fixtures/generated/fake.phar', '1.0.0-alpha1', '0.9.1-alpha1');

$temp = sys_get_temp_dir() . '/test-ws-download';
$updater = new Updater(__DIR__ . '/fixtures/generated/valid.json', new NullOutput());
$updater->update('0.9.0', '~0.9.0@alpha', $temp);

$this->assertFileExists($temp);
}

private function prepareReleasesFixture(string $name, string $releasePath, string $version1, string $version2): void
{
$contents = file_get_contents(__DIR__ . '/fixtures/tpl/releases.json');
$contents = str_replace(['%%browserDownloadUrl%%', '%%versionTag1%%', '%%versionTag2%%'], [$releasePath, $version1, $version2], $contents);

file_put_contents(__DIR__ . '/fixtures/generated/' . $name, $contents);
}

private function prepareLatestFixture(string $name, string $releasePath, string $version): void
{
$dir = dirname(__DIR__ . '/fixtures/generated/' . $name);
Expand Down
Loading

0 comments on commit 5e82a0d

Please sign in to comment.