diff --git a/.github/scripts/bin/downstream.php b/.github/scripts/bin/downstream.php new file mode 100755 index 0000000..2b2fa61 --- /dev/null +++ b/.github/scripts/bin/downstream.php @@ -0,0 +1,48 @@ +#!/usr/bin/env php +execute(array_slice($argv, 1)), +))->render(); + +if ($output !== '') { + $path = getenv('GITHUB_OUTPUT') + ?: throw new RuntimeException('GITHUB_OUTPUT is required'); + $written = file_put_contents($path, $output, FILE_APPEND | LOCK_EX); + if ($written !== strlen($output)) { + throw new RuntimeException('Unable to write workflow outputs'); + } +} diff --git a/.github/scripts/src/Downstream/Application.php b/.github/scripts/src/Downstream/Application.php new file mode 100644 index 0000000..e289015 --- /dev/null +++ b/.github/scripts/src/Downstream/Application.php @@ -0,0 +1,113 @@ + $arguments + * + * @return array + */ + public function execute(array $arguments): array + { + if ($arguments === []) { + throw new InvalidArgumentException( + 'A downstream operation is required', + ); + } + + [$operation, $values] = [array_shift($arguments), $arguments]; + + return match ([$operation, count($values)]) { + ['recover', 1] => $this->recover($values[0]), + ['propose', 1] => $this->propose($values[0]), + ['wait', 1] => $this->wait($this->integer($values[0])), + ['release', 2] => $this->release( + $this->integer($values[0]), + $values[1], + ), + default => throw new InvalidArgumentException( + "Unknown downstream operation '{$operation}'", + ), + }; + } + + /** + * @return array + */ + private function recover(string $version): array + { + $release = $this->orchestrator->recover($version); + if ($release === null) { + return ['recovered' => 'false']; + } + + return [ + 'recovered' => 'true', + 'tag' => (string) $release, + ]; + } + + /** + * @return array + */ + private function propose(string $version): array + { + $pull = $this->orchestrator->propose($version); + if ($pull === null) { + return ['changed' => 'false']; + } + + return [ + 'changed' => 'true', + 'pull' => (string) $pull->number, + 'head' => $pull->head, + 'base' => $pull->base, + ]; + } + + /** + * @return array + */ + private function wait(int $pull): array + { + $this->orchestrator->wait($pull); + + return ['checks' => 'success']; + } + + /** + * @return array + */ + private function release(int $pull, string $head): array + { + $release = $this->orchestrator->release($pull, $head); + + return [ + 'tag' => (string) $release, + 'application' => $release->application, + 'sub' => (string) $release->sub, + ]; + } + + private function integer(string $value): int + { + if (preg_match('/\A[1-9][0-9]*\z/', $value) !== 1) { + throw new InvalidArgumentException( + "Expected a positive integer, got '{$value}'", + ); + } + + return (int) $value; + } +} diff --git a/.github/scripts/src/Downstream/Bump.php b/.github/scripts/src/Downstream/Bump.php new file mode 100644 index 0000000..8794c21 --- /dev/null +++ b/.github/scripts/src/Downstream/Bump.php @@ -0,0 +1,20 @@ +current !== $this->selected; + } +} diff --git a/.github/scripts/src/Downstream/Checks.php b/.github/scripts/src/Downstream/Checks.php new file mode 100644 index 0000000..59952cf --- /dev/null +++ b/.github/scripts/src/Downstream/Checks.php @@ -0,0 +1,59 @@ + $checks + * @param list $required + * + * @return list + */ + public static function pending(array $checks, array $required): array + { + $concluded = []; + foreach ($checks as $check) { + if ($check['status'] === 'COMPLETED') { + $concluded[$check['name']] = $check['conclusion']; + } + } + + $pending = []; + foreach ($required as $context) { + if (! isset($concluded[$context])) { + $pending[] = $context; + } + } + sort($pending, SORT_STRING); + + return $pending; + } + + /** + * @param list $checks + * @param list $required + * + * @return list + */ + public static function failed(array $checks, array $required): array + { + $wanted = array_flip($required); + $failed = []; + foreach ($checks as $check) { + if ( + isset($wanted[$check['name']]) + && ! in_array($check['conclusion'], self::PASSING, true) + ) { + $failed[] = "{$check['name']}={$check['conclusion']}"; + } + } + sort($failed, SORT_STRING); + + return $failed; + } +} diff --git a/.github/scripts/src/Downstream/Constants.php b/.github/scripts/src/Downstream/Constants.php new file mode 100644 index 0000000..acbba3e --- /dev/null +++ b/.github/scripts/src/Downstream/Constants.php @@ -0,0 +1,32 @@ +'; + + private const string DOCKERFILE = 'Dockerfile'; + + private const string CONSTANTS = 'app/init/constants.php'; + + private const string BRANCH = 'automation/base-'; + + private const int TIMEOUT = 7200; + + private const int INTERVAL = 30; + + public function __construct( + private Repository $repository, + private Dockerfile $dockerfile, + private Constants $constants, + private Clock $clock, + private Sleeper $sleeper, + private string $base = 'main', + ) { + } + + public function propose(string $version): ?Pull + { + $head = $this->repository->head($this->base); + $bump = $this->dockerfile->bump( + $this->repository->file(self::DOCKERFILE, $head), + $version, + ); + if (! $bump->changed()) { + return null; + } + + $branch = self::BRANCH . $version; + $this->repository->commit( + $branch, + $head, + self::DOCKERFILE, + $bump->content, + "chore: update base image to {$version}", + ); + + return $this->repository->open( + $branch, + $this->base, + "chore: update base image to {$version}", + self::MARKER + . "\n" + . "\n\nAutomated base image update from `{$bump->current}`" + . " to `{$version}`.", + ); + } + + public function wait(int $pull): void + { + $required = $this->required(); + $deadline = Deadline::after($this->clock->now(), self::TIMEOUT); + + while (true) { + $checks = $this->repository->checks($pull); + $pending = Checks::pending($checks, $required); + if ($pending === []) { + $this->assertPassed($checks, $required); + + return; + } + + if ($deadline->expired($this->clock->now())) { + throw new Exception( + 'Base update CI did not conclude for pull request ' + . "#{$pull}: " . implode(', ', $pending), + ); + } + + $this->sleeper->sleep(self::INTERVAL); + } + } + + public function recover(string $version): ?Release + { + $target = $this->repository->mergeCommit(self::BRANCH . $version); + if ($target === null) { + return null; + } + + if (! $this->repository->contains($this->base, $target)) { + return null; + } + + foreach ($this->repository->tags(Release::PREFIX) as $tag) { + if ($tag->target === $target) { + return null; + } + } + + return $this->tag($target); + } + + public function release(int $pull, string $head): Release + { + $required = $this->required(); + $checks = $this->repository->checks($pull); + $pending = Checks::pending($checks, $required); + if ($pending !== []) { + throw new Exception( + 'Required checks are no longer concluded: ' + . implode(', ', $pending), + ); + } + $this->assertPassed($checks, $required); + + return $this->tag($this->repository->merge($pull, $head)); + } + + /** + * @return list + */ + private function required(): array + { + $required = $this->repository->required($this->base); + if ($required === []) { + throw new Exception( + "Branch '{$this->base}' declares no required status checks, " + . 'so a merge cannot be verified', + ); + } + + return $required; + } + + /** + * @param list $checks + * @param list $required + */ + private function assertPassed(array $checks, array $required): void + { + $failed = Checks::failed($checks, $required); + if ($failed !== []) { + throw new Exception( + 'Base update CI did not succeed: ' . implode(', ', $failed), + ); + } + } + + private function tag(string $target): Release + { + $application = $this->constants->application( + $this->repository->file(self::CONSTANTS, $target), + ); + $release = Release::next( + $application, + array_map( + static fn (Tag $tag): string => $tag->name, + $this->repository->tags(Release::PREFIX), + ), + ); + $this->repository->tag((string) $release, $target); + + return $release; + } +} diff --git a/.github/scripts/src/Downstream/Pull.php b/.github/scripts/src/Downstream/Pull.php new file mode 100644 index 0000000..102487b --- /dev/null +++ b/.github/scripts/src/Downstream/Pull.php @@ -0,0 +1,15 @@ +application)) { + throw new Exception( + "Application version must be MAJOR.MINOR.PATCH, got '{$this->application}'", + ); + } + if ($this->sub < 1) { + throw new Exception('Release sub-version must be positive'); + } + } + + /** + * @param list $tags + */ + public static function next(string $application, array $tags): self + { + $pattern = '/\A' . preg_quote(self::PREFIX, '/') + . preg_quote($application, '/') + . '-([0-9]+)\z/'; + + $highest = 0; + foreach ($tags as $tag) { + if (preg_match($pattern, $tag, $matched) !== 1) { + continue; + } + + $sub = (int) $matched[1]; + if ((string) $sub !== $matched[1]) { + throw new Exception( + "Release tag '{$tag}' has a non-canonical sub-version", + ); + } + if ($sub > $highest) { + $highest = $sub; + } + } + + return new self($application, $highest + 1); + } + + #[Override] + public function __toString(): string + { + return self::PREFIX . "{$this->application}-{$this->sub}"; + } +} diff --git a/.github/scripts/src/Downstream/Repository.php b/.github/scripts/src/Downstream/Repository.php new file mode 100644 index 0000000..0efbb28 --- /dev/null +++ b/.github/scripts/src/Downstream/Repository.php @@ -0,0 +1,50 @@ + + */ + public function tags(string $prefix): array; + + public function mergeCommit(string $branch): ?string; + + public function contains(string $branch, string $commit): bool; + + /** + * @return list + */ + public function required(string $branch): array; + + public function commit( + string $branch, + string $base, + string $path, + string $content, + string $message, + ): string; + + public function open( + string $branch, + string $base, + string $title, + string $body, + ): Pull; + + /** + * @return list + */ + public function checks(int $pull): array; + + public function merge(int $pull, string $head): string; + + public function tag(string $name, string $target): void; +} diff --git a/.github/scripts/src/Downstream/Repository/GitHub.php b/.github/scripts/src/Downstream/Repository/GitHub.php new file mode 100644 index 0000000..f8705fe --- /dev/null +++ b/.github/scripts/src/Downstream/Repository/GitHub.php @@ -0,0 +1,349 @@ +repository) !== 1) { + throw new Exception( + "Invalid GitHub repository '{$this->repository}'", + ); + } + } + + #[Override] + public function file(string $path, string $ref): string + { + $encoded = $this->text([ + 'gh', 'api', '-X', 'GET', + "repos/{$this->repository}/contents/{$path}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '-f', "ref={$ref}", + '--jq', '.content', + ]); + $content = base64_decode(str_replace("\n", '', $encoded), true); + if ($content === false) { + throw new Exception("Unable to decode {$path} at {$ref}"); + } + + return $content; + } + + #[Override] + public function head(string $branch): string + { + return $this->sha( + $this->text([ + 'gh', 'api', '-X', 'GET', + "repos/{$this->repository}/commits/{$branch}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '--jq', '.sha', + ]), + "head of {$branch}", + ); + } + + /** + * @return list + */ + #[Override] + public function tags(string $prefix): array + { + $output = $this->text([ + 'gh', 'api', '--paginate', + "repos/{$this->repository}/git/matching-refs/tags/{$prefix}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '--jq', '.[] | "\(.ref)\t\(.object.sha)"', + ]); + + $tags = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + $fields = explode("\t", trim($line)); + if ( + count($fields) !== 2 + || ! str_starts_with($fields[0], 'refs/tags/') + ) { + continue; + } + + $tags[] = new Tag( + substr($fields[0], strlen('refs/tags/')), + $fields[1], + ); + } + + return $tags; + } + + #[Override] + public function mergeCommit(string $branch): ?string + { + $output = $this->text([ + 'gh', 'pr', 'list', + '--repo', $this->repository, + '--head', $branch, + '--state', 'merged', + '--json', 'mergeCommit', + '--jq', '.[0].mergeCommit.oid // ""', + ]); + if (trim($output) === '') { + return null; + } + + return $this->sha($output, "merge commit for {$branch}"); + } + + #[Override] + public function contains(string $branch, string $commit): bool + { + $status = $this->text([ + 'gh', 'api', '-X', 'GET', + "repos/{$this->repository}/compare/{$branch}...{$commit}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '--jq', '.status', + ]); + + return in_array(trim($status), ['identical', 'behind'], true); + } + + /** + * @return list + */ + #[Override] + public function required(string $branch): array + { + $output = $this->text([ + 'gh', 'api', '-X', 'GET', + "repos/{$this->repository}/branches/{$branch}/protection", + '-H', "X-GitHub-Api-Version: {$this->version}", + '--jq', '.required_status_checks.contexts // [] | .[]', + ]); + + $contexts = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + $line = trim($line); + if ($line !== '') { + $contexts[] = $line; + } + } + + return $contexts; + } + + #[Override] + public function commit( + string $branch, + string $base, + string $path, + string $content, + string $message, + ): string { + $this->runner->run([ + 'gh', 'api', '-X', 'POST', + "repos/{$this->repository}/git/refs", + '-H', "X-GitHub-Api-Version: {$this->version}", + '-f', "ref=refs/heads/{$branch}", + '-f', "sha={$base}", + ]); + + $existing = $this->text([ + 'gh', 'api', '-X', 'GET', + "repos/{$this->repository}/contents/{$path}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '-f', "ref={$branch}", + '--jq', '.sha', + ]); + + return $this->sha( + $this->text([ + 'gh', 'api', '-X', 'PUT', + "repos/{$this->repository}/contents/{$path}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '-f', "branch={$branch}", + '-f', "message={$message}", + '-f', 'content=' . base64_encode($content), + '-f', 'sha=' . trim($existing), + '--jq', '.commit.sha', + ]), + 'update commit', + ); + } + + #[Override] + public function open( + string $branch, + string $base, + string $title, + string $body, + ): Pull { + $payload = $this->json([ + 'gh', 'api', '-X', 'POST', + "repos/{$this->repository}/pulls", + '-H', "X-GitHub-Api-Version: {$this->version}", + '-f', "title={$title}", + '-f', "head={$branch}", + '-f', "base={$base}", + '-f', "body={$body}", + ]); + + $number = $payload['number'] ?? null; + $head = $payload['head'] ?? null; + if (! is_int($number) || ! is_array($head)) { + throw new Exception('Pull request creation returned no number'); + } + + return new Pull( + $number, + $this->sha( + is_string($head['sha'] ?? null) ? $head['sha'] : '', + "head of pull request #{$number}", + ), + $base, + ); + } + + /** + * @return list + */ + #[Override] + public function checks(int $pull): array + { + $payload = $this->json([ + 'gh', 'pr', 'view', (string) $pull, + '--repo', $this->repository, + '--json', 'statusCheckRollup', + ]); + + $rollup = $payload['statusCheckRollup'] ?? null; + if (! is_array($rollup)) { + throw new Exception( + "Unable to read checks for pull request #{$pull}", + ); + } + + $checks = []; + foreach ($rollup as $check) { + if (! is_array($check)) { + continue; + } + + $name = $check['name'] ?? $check['context'] ?? ''; + $status = $check['status'] ?? $check['state'] ?? ''; + $conclusion = $check['conclusion'] ?? ''; + $checks[] = [ + 'name' => is_string($name) ? $name : '', + 'status' => is_string($status) ? strtoupper($status) : '', + 'conclusion' => is_string($conclusion) + ? strtoupper($conclusion) + : '', + ]; + } + + return $checks; + } + + #[Override] + public function merge(int $pull, string $head): string + { + $this->runner->run([ + 'gh', 'pr', 'merge', (string) $pull, + '--repo', $this->repository, + '--squash', + '--admin', + '--match-head-commit', $head, + ]); + + $target = $this->text([ + 'gh', 'pr', 'view', (string) $pull, + '--repo', $this->repository, + '--json', 'mergeCommit', + '--jq', '.mergeCommit.oid', + ]); + + return $this->sha($target, "merge commit for #{$pull}"); + } + + #[Override] + public function tag(string $name, string $target): void + { + $this->runner->run([ + 'gh', 'api', '-X', 'POST', + "repos/{$this->repository}/git/refs", + '-H', "X-GitHub-Api-Version: {$this->version}", + '-f', "ref=refs/tags/{$name}", + '-f', "sha={$target}", + ]); + + $created = $this->text([ + 'gh', 'api', '-X', 'GET', + "repos/{$this->repository}/git/ref/tags/{$name}", + '-H', "X-GitHub-Api-Version: {$this->version}", + '--jq', '.object.sha', + ]); + if (trim($created) !== $target) { + throw new Exception( + "Tag {$name} does not point at {$target}", + ); + } + } + + /** + * @param list $command + */ + private function text(array $command): string + { + return trim($this->runner->run($command)->output); + } + + /** + * @param list $command + * + * @return array + */ + private function json(array $command): array + { + $output = $this->runner->run($command)->output; + + try { + $payload = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new Exception( + 'Unable to decode GitHub response: ' + . $exception->getMessage(), + previous: $exception, + ); + } + + if (! is_array($payload)) { + throw new Exception('GitHub returned an unexpected response'); + } + + /** @var array $payload */ + return $payload; + } + + private function sha(string $value, string $subject): string + { + $value = trim($value); + if (preg_match('/\A[0-9a-f]{40}\z/', $value) !== 1) { + throw new Exception("Unable to read the {$subject}"); + } + + return $value; + } +} diff --git a/.github/scripts/src/Downstream/Tag.php b/.github/scripts/src/Downstream/Tag.php new file mode 100644 index 0000000..c68a12b --- /dev/null +++ b/.github/scripts/src/Downstream/Tag.php @@ -0,0 +1,14 @@ +bump($content, '2.0.1'); + + self::assertSame('2.0.0', $bump->current); + self::assertSame('2.0.1', $bump->selected); + self::assertSame(true, $bump->changed()); + self::assertSame( + "FROM appwrite/base:2.0.1 AS base\n" + . "FROM appwrite/base:2.0.1-xdebug AS xdebug\n" + . "# appwrite/base:2.0.1 ships without XDebug\n", + $bump->content, + ); + } + + public function test_reports_no_change_when_already_current(): void + { + $bump = (new Dockerfile())->bump( + "FROM appwrite/base:2.0.1 AS base\n", + '2.0.1', + ); + + self::assertSame(false, $bump->changed()); + } + + public function test_rejects_conflicting_versions(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + 'Conflicting appwrite/base versions: 1.4.3, 2.0.0', + ); + + (new Dockerfile())->bump( + "FROM appwrite/base:2.0.0 AS base\n" + . "FROM appwrite/base:1.4.3 AS other\n", + '2.0.1', + ); + } + + public function test_rejects_a_dockerfile_with_no_reference(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('No appwrite/base reference found'); + + (new Dockerfile())->bump("FROM php:8.5-alpine\n", '2.0.1'); + } + + public function test_rejects_an_inexact_selected_version(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('exact MAJOR.MINOR.PATCH'); + + (new Dockerfile())->bump("FROM appwrite/base:2.0.0\n", '2.0'); + } +} diff --git a/.github/scripts/tests/Unit/Downstream/ChecksTest.php b/.github/scripts/tests/Unit/Downstream/ChecksTest.php new file mode 100644 index 0000000..9b7bf4a --- /dev/null +++ b/.github/scripts/tests/Unit/Downstream/ChecksTest.php @@ -0,0 +1,94 @@ + $name, + 'status' => $status, + 'conclusion' => $conclusion, + ]; + } +} diff --git a/.github/scripts/tests/Unit/Downstream/Fake.php b/.github/scripts/tests/Unit/Downstream/Fake.php new file mode 100644 index 0000000..6b90b21 --- /dev/null +++ b/.github/scripts/tests/Unit/Downstream/Fake.php @@ -0,0 +1,149 @@ + */ + public array $calls = []; + + public ?string $tagged = null; + + /** + * @param list $tags + * @param list $requiredChecks + * @param list> $rounds + */ + public function __construct( + private readonly string $dockerfile, + private readonly string $constants = "calls[] = "file:{$path}"; + + return str_ends_with($path, 'constants.php') + ? $this->constants + : $this->dockerfile; + } + + #[Override] + public function head(string $branch): string + { + $this->calls[] = "head:{$branch}"; + + return $this->head; + } + + /** + * @return list + */ + #[Override] + public function tags(string $prefix): array + { + $this->calls[] = "tags:{$prefix}"; + + return $this->tags === [] + ? [new Tag('cl-1.9.6-1', 'c0000000000000000000000000000000000000cc')] + : $this->tags; + } + + /** + * @return list + */ + #[Override] + public function required(string $branch): array + { + $this->calls[] = "required:{$branch}"; + + return $this->requiredChecks; + } + + #[Override] + public function contains(string $branch, string $commit): bool + { + $this->calls[] = "contains:{$branch}"; + + return $this->contained; + } + + #[Override] + public function mergeCommit(string $branch): ?string + { + $this->calls[] = "merged:{$branch}"; + + return $this->merged; + } + + #[Override] + public function commit( + string $branch, + string $base, + string $path, + string $content, + string $message, + ): string { + $this->calls[] = "commit:{$branch}"; + + return $this->mergeCommit; + } + + #[Override] + public function open( + string $branch, + string $base, + string $title, + string $body, + ): Pull { + $this->calls[] = "open:{$branch}->{$base}"; + + return new Pull(93, $this->head, $base); + } + + /** + * @return list + */ + #[Override] + public function checks(int $pull): array + { + $this->calls[] = "checks:{$pull}"; + if ($this->rounds === []) { + throw new Exception('No further check rounds'); + } + + return array_shift($this->rounds); + } + + #[Override] + public function merge(int $pull, string $head): string + { + $this->calls[] = "merge:{$pull}@{$head}"; + + return $this->mergeCommit; + } + + #[Override] + public function tag(string $name, string $target): void + { + $this->calls[] = "tag:{$name}@{$target}"; + $this->tagged = $name; + } +} diff --git a/.github/scripts/tests/Unit/Downstream/OrchestratorTest.php b/.github/scripts/tests/Unit/Downstream/OrchestratorTest.php new file mode 100644 index 0000000..0783ba6 --- /dev/null +++ b/.github/scripts/tests/Unit/Downstream/OrchestratorTest.php @@ -0,0 +1,265 @@ +orchestrator($repository)->propose('2.0.1'); + + self::assertNotNull($pull); + self::assertSame(93, $pull->number); + self::assertSame('main', $pull->base); + self::assertSame( + [ + 'head:main', + 'file:Dockerfile', + 'commit:automation/base-2.0.1', + 'open:automation/base-2.0.1->main', + ], + $repository->calls, + ); + } + + public function test_opens_nothing_when_the_base_is_already_current(): void + { + $repository = new Fake("FROM appwrite/base:2.0.1 AS base\n"); + + self::assertNull($this->orchestrator($repository)->propose('2.0.1')); + self::assertSame( + ['head:main', 'file:Dockerfile'], + $repository->calls, + ); + } + + public function test_waits_until_every_required_check_concludes(): void + { + $repository = new Fake( + self::DOCKERFILE, + rounds: [ + [self::check('Tests / Unit', 'IN_PROGRESS', '')], + [self::check('Tests / Unit', 'COMPLETED', 'SUCCESS')], + ], + ); + + $this->orchestrator($repository)->wait(93); + + self::assertSame( + ['required:main', 'checks:93', 'checks:93'], + $repository->calls, + ); + } + + public function test_waits_for_a_required_check_that_registers_late(): void + { + $repository = new Fake( + self::DOCKERFILE, + rounds: [ + [self::check('lint', 'COMPLETED', 'SUCCESS')], + [self::check('lint', 'COMPLETED', 'SUCCESS')], + [ + self::check('lint', 'COMPLETED', 'SUCCESS'), + self::check('Tests / Unit', 'IN_PROGRESS', ''), + ], + [ + self::check('lint', 'COMPLETED', 'SUCCESS'), + self::check('Tests / Unit', 'COMPLETED', 'SUCCESS'), + ], + ], + ); + + $this->orchestrator($repository)->wait(93); + + self::assertSame(5, count($repository->calls)); + } + + public function test_refuses_to_merge_when_nothing_is_required(): void + { + $repository = new Fake(self::DOCKERFILE, requiredChecks: []); + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "Branch 'main' declares no required status checks", + ); + + $this->orchestrator($repository)->wait(93); + } + + public function test_refuses_to_continue_when_a_check_failed(): void + { + $repository = new Fake( + self::DOCKERFILE, + rounds: [[self::check('Tests / Unit', 'COMPLETED', 'FAILURE')]], + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + 'Base update CI did not succeed: Tests / Unit=FAILURE', + ); + + $this->orchestrator($repository)->wait(93); + } + + public function test_refuses_to_merge_a_check_that_went_pending_again(): void + { + $repository = new Fake( + self::DOCKERFILE, + rounds: [[self::check('Tests / Unit', 'IN_PROGRESS', '')]], + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + 'Required checks are no longer concluded: Tests / Unit', + ); + + $this->orchestrator($repository)->release(93, self::HEAD); + } + + public function test_refuses_to_merge_a_check_that_failed_after_waiting(): void + { + $repository = new Fake( + self::DOCKERFILE, + rounds: [[self::check('Tests / Unit', 'COMPLETED', 'FAILURE')]], + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + 'Base update CI did not succeed: Tests / Unit=FAILURE', + ); + + $this->orchestrator($repository)->release(93, self::HEAD); + } + + public function test_merges_then_tags_the_merge_commit(): void + { + $repository = new Fake( + self::DOCKERFILE, + rounds: [[self::check('Tests / Unit', 'COMPLETED', 'SUCCESS')]], + ); + $head = 'a0000000000000000000000000000000000000aa'; + + $release = $this->orchestrator($repository)->release(93, $head); + + self::assertSame('cl-1.9.6-2', (string) $release); + self::assertSame('cl-1.9.6-2', $repository->tagged); + self::assertSame( + [ + 'required:main', + 'checks:93', + "merge:93@{$head}", + 'file:app/init/constants.php', + 'tags:cl-', + 'tag:cl-1.9.6-2@b0000000000000000000000000000000000000bb', + ], + $repository->calls, + ); + } + + public function test_tags_a_merge_that_never_got_its_tag(): void + { + $merge = 'b0000000000000000000000000000000000000bb'; + $repository = new Fake( + "FROM appwrite/base:2.0.1 AS base\n", + head: $merge, + merged: $merge, + ); + + $release = $this->orchestrator($repository)->recover('2.0.1'); + + self::assertNotNull($release); + self::assertSame('cl-1.9.6-2', (string) $release); + self::assertSame('cl-1.9.6-2', $repository->tagged); + } + + public function test_does_not_recover_a_merge_already_tagged(): void + { + $merge = 'b0000000000000000000000000000000000000bb'; + $repository = new Fake( + "FROM appwrite/base:2.0.1 AS base\n", + tags: [new Tag('cl-1.9.6-2', $merge)], + head: $merge, + merged: $merge, + ); + + self::assertNull($this->orchestrator($repository)->recover('2.0.1')); + self::assertNull($repository->tagged); + } + + public function test_recovers_after_main_has_moved_past_the_merge(): void + { + $repository = new Fake( + "FROM appwrite/base:2.0.1 AS base\n", + head: 'd0000000000000000000000000000000000000dd', + merged: 'b0000000000000000000000000000000000000bb', + ); + + $release = $this->orchestrator($repository)->recover('2.0.1'); + + self::assertNotNull($release); + self::assertSame('cl-1.9.6-2', (string) $release); + } + + public function test_does_not_recover_a_merge_absent_from_the_branch(): void + { + $repository = new Fake( + "FROM appwrite/base:2.0.1 AS base\n", + merged: 'b0000000000000000000000000000000000000bb', + contained: false, + ); + + self::assertNull($this->orchestrator($repository)->recover('2.0.1')); + self::assertNull($repository->tagged); + } + + public function test_recovers_nothing_without_a_merged_pull_request(): void + { + $repository = new Fake(self::DOCKERFILE); + + self::assertNull($this->orchestrator($repository)->recover('2.0.1')); + } + + /** + * @return array{name: string, status: string, conclusion: string} + */ + private static function check( + string $name, + string $status, + string $conclusion, + ): array { + return [ + 'name' => $name, + 'status' => $status, + 'conclusion' => $conclusion, + ]; + } + + private function orchestrator(Fake $repository): Orchestrator + { + return new Orchestrator( + $repository, + new Dockerfile(), + new Constants(), + new Ticker(), + $this->createStub(Sleeper::class), + ); + } +} diff --git a/.github/scripts/tests/Unit/Downstream/ReleaseTest.php b/.github/scripts/tests/Unit/Downstream/ReleaseTest.php new file mode 100644 index 0000000..4309b14 --- /dev/null +++ b/.github/scripts/tests/Unit/Downstream/ReleaseTest.php @@ -0,0 +1,92 @@ +application); + self::assertSame(3, $release->sub); + } + + public function test_starts_at_one_for_an_unreleased_application(): void + { + self::assertSame( + 'cl-2.0.0-1', + (string) Release::next('2.0.0', ['cl-1.9.6-9']), + ); + } + + public function test_selects_the_semantic_maximum_sub_version(): void + { + self::assertSame( + 'cl-1.9.6-11', + (string) Release::next('1.9.6', [ + 'cl-1.9.6-9', + 'cl-1.9.6-10', + 'cl-1.9.6-2', + ]), + ); + } + + public function test_ignores_unrelated_and_prefixed_tags(): void + { + self::assertSame( + 'cl-1.9.6-1', + (string) Release::next('1.9.6', [ + 'cl-1.9.6-1-rc1', + 'cl-1.9.60-4', + '1.9.6-7', + 'cl-shared-tables-zdt-6', + ]), + ); + } + + public function test_rejects_a_non_canonical_sub_version(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('non-canonical sub-version'); + + Release::next('1.9.6', ['cl-1.9.6-01']); + } + + public function test_reads_the_stable_application_version(): void + { + self::assertSame( + '1.9.6', + (new Constants())->application( + "expectException(Exception::class); + $this->expectExceptionMessage( + 'Expected exactly one APP_VERSION_STABLE declaration, found 0', + ); + + (new Constants())->application("ticks * $this->seconds; + ++$this->ticks; + + return (new DateTimeImmutable( + '2026-08-21T00:00:00+00:00', + new DateTimeZone('UTC'), + ))->modify("+{$elapsed} seconds"); + } +} diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 47d5438..17428ab 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -16,6 +16,8 @@ permissions: pull-requests: write env: + DOWNSTREAM_BRANCH: main + DOWNSTREAM_REPOSITORY: appwrite/appwrite GITHUB_API_VERSION: '2026-03-10' jobs: @@ -254,3 +256,51 @@ jobs: run: | php .github/scripts/bin/orchestrator.php publish \ "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" + + - name: Recover an untagged downstream release + id: downstream_recovery + if: steps.release.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.DOWNSTREAM_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + run: | + php .github/scripts/bin/downstream.php recover "${TAG}" + + - name: Propose the downstream base update + id: downstream + if: steps.downstream_recovery.outputs.recovered == 'false' + env: + GH_TOKEN: ${{ secrets.DOWNSTREAM_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + run: | + php .github/scripts/bin/downstream.php propose "${TAG}" + + - name: Wait for downstream CI + if: steps.downstream.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.DOWNSTREAM_TOKEN }} + PULL: ${{ steps.downstream.outputs.pull }} + run: | + php .github/scripts/bin/downstream.php wait "${PULL}" + + - name: Merge and tag the downstream release + id: downstream_release + if: steps.downstream.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.DOWNSTREAM_TOKEN }} + HEAD: ${{ steps.downstream.outputs.head }} + PULL: ${{ steps.downstream.outputs.pull }} + run: | + php .github/scripts/bin/downstream.php release "${PULL}" "${HEAD}" + + - name: Summarise the downstream release + if: >- + steps.downstream_release.outcome == 'success' || + steps.downstream_recovery.outputs.recovered == 'true' + env: + TAG: >- + ${{ steps.downstream_release.outputs.tag || + steps.downstream_recovery.outputs.tag }} + run: | + printf '## Downstream release\n\nTagged `%s` in `%s`.\n' \ + "${TAG}" "${DOWNSTREAM_REPOSITORY}" >> "${GITHUB_STEP_SUMMARY}" diff --git a/CHANGES.md b/CHANGES.md index 201debf..ec63adb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,10 @@ * `verify.yml` runs `composer validate --strict`, `composer check-platform-reqs`, and `composer verify` on every push, so the automation is gated at pull-request time rather than only on the Monday run that uses it. * Dependabot now tracks the `composer` ecosystem. The automation is only as trustworthy as the Pint, PHPStan, and PHPUnit versions gating it. +### Add + +* Downstream base bump. After a base release publishes, the weekly job opens a pull request in `appwrite/appwrite` rewriting every `appwrite/base:` reference in its `Dockerfile`, waits for that pull request's checks to conclude, merges it, and tags the merge commit `cl-{APP_VERSION_STABLE}-{n}` — reading the application version from `app/init/constants.php` and taking the next unused sub-version for it. Lives in `.github/scripts/src/Downstream`, driven by `bin/downstream.php`. The wait reads the downstream branch's required status-check contexts and holds until every one of them has concluded, rather than inferring completeness from whichever checks happen to be visible. Downstream CI expands a dynamic matrix into dozens of checks that register minutes apart, so a visible-checks heuristic can never tell a finished run from one that has not started; a declared required set can. A branch with no required contexts is refused outright, because `--admin` bypasses branch protection and an unverifiable merge would otherwise proceed. The required set is re-read immediately before the merge as well as during the wait, so a check re-run between the two steps cannot be bypassed. A release that merged but never got its tag is recovered on the next run, bounded to the downstream tip so a superseded merge is not resurrected. Requires a `DOWNSTREAM_TOKEN` secret with admin rights on the downstream repository, because `main` there requires an approving review and GitHub forbids self-approval; the merge bypasses that review requirement but never the checks. + ### Fix * The updater rewrote `PHP_*_VERSION` and left `PHP_*_COMMIT` / `PHP_*_CHECKSUM` at the superseded release. Protobuf failed loudly on the checksum, but the git-sourced extensions did not: the build fetched the old commit and shipped, say, brotli 0.20.0 in an image labelled 0.21.0. `Dockerfile::pins()` only ever located the version variable, so no companion reference was ever a candidate for replacement. Every dependency now carries its reference variable through the catalog, resolver, selector, and rewriter, and both move together or neither does.