diff --git a/.github/scripts/bin/dependencies.php b/.github/scripts/bin/dependencies.php new file mode 100755 index 0000000..1da08bd --- /dev/null +++ b/.github/scripts/bin/dependencies.php @@ -0,0 +1,38 @@ +#!/usr/bin/env php +execute(array_slice($argv, 1)); + +if ($result->output !== '') { + fwrite(STDOUT, $result->output); +} +if ($result->error !== '') { + fwrite(STDERR, $result->error); +} + +exit($result->code); diff --git a/.github/scripts/bin/orchestrator.php b/.github/scripts/bin/orchestrator.php new file mode 100755 index 0000000..6e8ffff --- /dev/null +++ b/.github/scripts/bin/orchestrator.php @@ -0,0 +1,49 @@ +#!/usr/bin/env php +execute($arguments, $input), +))->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/bin/parity.php b/.github/scripts/bin/parity.php new file mode 100755 index 0000000..93701cf --- /dev/null +++ b/.github/scripts/bin/parity.php @@ -0,0 +1,43 @@ +#!/usr/bin/env php + 2) { + fwrite(STDERR, 'Usage: parity.php RESULTS [MANIFEST]' . PHP_EOL); + + exit(2); +} + +$results = $arguments[0]; +$manifest = $arguments[1] + ?? $root . '/.github/scripts/tests/equivalence.json'; + +try { + $count = (new Verifier($manifest, $results))->verify(); +} catch (\Throwable $exception) { + $detail = preg_replace( + '/\s+/', + ' ', + trim($exception->getMessage()), + ); + fwrite( + STDERR, + 'Error: ' + . ($detail === null || $detail === '' + ? 'Parity verification failed' + : $detail) + . PHP_EOL, + ); + + exit(1); +} + +fwrite(STDOUT, "Verified {$count} parity contracts." . PHP_EOL); diff --git a/.github/scripts/src/Automation/Application.php b/.github/scripts/src/Automation/Application.php new file mode 100644 index 0000000..64cf8b5 --- /dev/null +++ b/.github/scripts/src/Automation/Application.php @@ -0,0 +1,163 @@ + $arguments + * + * @return array + */ + public function execute(array $arguments, string $input = ''): array + { + if ($arguments === []) { + throw new InvalidArgumentException( + 'An orchestration operation is required', + ); + } + + [$operation, $values] = [ + array_shift($arguments), + $arguments, + ]; + + return match ([$operation, count($values)]) { + ['recover', 0] => $this->recover(), + ['validate-pull', 3] => Payload::validate( + $input, + $values[0], + $values[1], + $values[2], + ), + ['wait-checks', 3] => $this->checks( + $values[0], + $values[1], + $values[2], + ), + ['merge', 3] => [ + 'head' => $this->orchestrator->merge( + $this->integer($values[0]), + $values[1], + $values[2], + ), + ], + ['prepare', 4] => $this->preparation( + $this->orchestrator->prepare( + $values[0] === '' ? null : $values[0], + $values[1], + $this->integer($values[2]), + $values[3] === '' ? null : $this->integer($values[3]), + ), + ), + ['wait', 2] => $this->wait($values[0], $values[1]), + ['publish', 4] => $this->publish( + $values[0], + $values[1], + $this->integer($values[2]), + $this->integer($values[3]), + ), + default => throw new InvalidArgumentException( + "Invalid '{$operation}' arguments", + ), + }; + } + + /** + * @return array + */ + private function recover(): array + { + $candidate = $this->orchestrator->recover(); + if ($candidate === null) { + return ['pending' => 'false']; + } + + return [ + 'pending' => 'true', + 'tag' => $candidate->tag ?? '', + 'head' => $candidate->target, + 'pull' => (string) $candidate->pull, + 'draft' => $candidate->draft === null + ? '' + : (string) $candidate->draft, + ]; + } + + /** + * @return array + */ + private function preparation(Preparation $preparation): array + { + return [ + 'tag' => $preparation->tag, + 'head' => $preparation->target, + 'pull' => (string) $preparation->pull, + 'draft' => (string) $preparation->draft, + ]; + } + + /** + * @return array + */ + private function wait(string $tag, string $target): array + { + $this->orchestrator->wait($tag, $target); + + return []; + } + + /** + * @return array + */ + private function checks( + string $branch, + string $head, + string $created, + ): array { + $this->orchestrator->checks($branch, $head, $created); + + return []; + } + + /** + * @return array + */ + private function publish( + string $tag, + string $target, + int $pull, + int $draft, + ): array { + $this->orchestrator->publish($tag, $target, $pull, $draft); + + return []; + } + + private function integer(string $value): int + { + if ( + filter_var( + $value, + FILTER_VALIDATE_INT, + FILTER_NULL_ON_FAILURE, + ) === null + ) { + throw new InvalidArgumentException( + "Invalid integer '{$value}'", + ); + } + + return (int) $value; + } +} diff --git a/.github/scripts/src/Automation/ApprovalMissingException.php b/.github/scripts/src/Automation/ApprovalMissingException.php new file mode 100644 index 0000000..edbd8a5 --- /dev/null +++ b/.github/scripts/src/Automation/ApprovalMissingException.php @@ -0,0 +1,9 @@ +modify("+{$seconds} seconds")); + } + + public function expired(DateTimeImmutable $now): bool + { + return $now >= $this->at; + } + + public function remaining(DateTimeImmutable $now): int + { + $remaining = (float) $this->at->format('U.u') + - (float) $now->format('U.u'); + + return max((int) ceil($remaining), 0); + } +} diff --git a/.github/scripts/src/Automation/HeadChangedException.php b/.github/scripts/src/Automation/HeadChangedException.php new file mode 100644 index 0000000..8ce3965 --- /dev/null +++ b/.github/scripts/src/Automation/HeadChangedException.php @@ -0,0 +1,9 @@ +'; + + /** + * @param list $parents + * @param list $files + */ + public function __construct( + public int $number, + public string $target, + public string $head, + public array $parents, + public string $base, + public string $branch, + public string $body, + public array $files, + public string $state, + ) { + } +} diff --git a/.github/scripts/src/Automation/MergeResult.php b/.github/scripts/src/Automation/MergeResult.php new file mode 100644 index 0000000..61d30b3 --- /dev/null +++ b/.github/scripts/src/Automation/MergeResult.php @@ -0,0 +1,19 @@ + $parents + */ + public function __construct( + public string $head, + public string $state, + public ?string $commit, + public array $parents, + ) { + } +} diff --git a/.github/scripts/src/Automation/MergeValidator.php b/.github/scripts/src/Automation/MergeValidator.php new file mode 100644 index 0000000..624a4ac --- /dev/null +++ b/.github/scripts/src/Automation/MergeValidator.php @@ -0,0 +1,106 @@ +body); + if ($lines === false) { + return false; + } + + $head = ""; + $parent = count($merge->parents) === 1 + ? "" + : ''; + + return $merge->state === 'merged' + && $merge->base === 'main' + && str_starts_with( + $merge->branch, + 'automation/dependencies-', + ) + && self::count($lines, Merge::MARKER) === 1 + && self::count($lines, $head) === 1 + && self::countPrefix( + $lines, + '' + . "\n" + . "\n" + . "\n\nAutomated weekly dependency release."; + + private const int TIMEOUT = 7200; + + private const int INTERVAL = 20; + + /** + * @var list + */ + private const array WORKFLOWS = [ + ['build-and-push.yml', 'Build and Push', 'push'], + ['dive.yml', 'Dive Test', 'push'], + ['structure-test.yml', 'Container Structure Test', 'push'], + ['trivy.yml', 'Trivy Scan', 'pull_request'], + ]; + + public function __construct( + private Repository $repository, + private Clock $clock, + private Sleeper $sleeper, + ) { + } + + public function recover(): ?Candidate + { + $tags = $this->repository->tags(); + + return RecoverySelector::select( + $tags, + $this->repository->releases($tags), + $this->repository->mergedPullRequests(), + ); + } + + public function merge(int $pull, string $head, string $base): string + { + $request = $this->repository->pullRequest($pull); + if ($request->baseBranch !== 'main') { + throw new PullRequestUnavailableException( + 'Pull request does not target main', + ); + } + PullRequestValidator::validate($request, $head, $base); + if (! $request->mergeable) { + throw new PullRequestUnavailableException( + 'Pull request is not currently mergeable', + ); + } + + return MergeValidator::validateResult( + $this->repository->squashMerge($pull, $head, $base), + $head, + $base, + ); + } + + public function prepare( + ?string $tag, + string $target, + int $pull, + ?int $draft, + ): Preparation { + $name = $tag ?? $this->createTag($target); + $this->validateTag($name, $target, 'does not exist'); + $body = $this->body($target, $pull); + $release = $draft === null + ? $this->repository->createDraft( + $name, + $target, + $pull, + $body, + ) + : $this->repository->draft($draft); + $this->validateDraft($release, $name, $target, $pull, $body); + $this->validateTag( + $name, + $target, + 'disappeared during preparation', + ); + + return new Preparation( + tag: $name, + target: $target, + pull: $pull, + draft: $release->identifier, + ); + } + + public function wait(string $tag, string $target): void + { + $boundary = new DateTimeImmutable( + '1970-01-01T00:00:00+00:00', + new DateTimeZone('UTC'), + ); + $deadline = Deadline::after($this->clock->now(), self::TIMEOUT); + while (true) { + $this->validateTag( + $tag, + $target, + 'disappeared during its build', + ); + $now = $this->clock->now(); + $state = RunEvaluator::state( + runs: $this->repository->runs( + 'build-and-push.yml', + 'push', + $target, + $tag, + ), + workflow: 'Build and Push', + event: 'push', + head: $target, + branch: $tag, + created: $boundary, + deadline: $deadline, + now: $now, + ); + if ($state === WorkflowState::Succeeded) { + return; + } + if ( + in_array( + $state, + [ + WorkflowState::Cancelled, + WorkflowState::Failed, + WorkflowState::TimedOut, + ], + true, + ) + ) { + throw new RuntimeException( + "Tag Build and Push did not succeed: {$state->value}", + ); + } + + $this->sleeper->sleep( + min(self::INTERVAL, max($deadline->remaining($this->clock->now()), 0)), + ); + } + } + + public function checks( + string $branch, + string $head, + string $created, + ): void { + $boundary = DateTimeImmutable::createFromFormat( + '!Y-m-d\TH:i:s\Z', + $created, + new DateTimeZone('UTC'), + ); + if ($boundary === false || $boundary->format('Y-m-d\TH:i:s\Z') !== $created) { + throw new InvalidArgumentException( + 'Workflow boundary must be an absolute UTC timestamp', + ); + } + + $deadline = Deadline::after( + $this->clock->now(), + Deadline::WORKFLOW_TIMEOUT_SECONDS, + ); + while (true) { + $now = $this->clock->now(); + $states = []; + foreach (self::WORKFLOWS as [$filename, $workflow, $event]) { + $states[$workflow] = RunEvaluator::state( + runs: $this->repository->runs( + $filename, + $event, + $head, + $branch, + ), + workflow: $workflow, + event: $event, + head: $head, + branch: $branch, + created: $boundary, + deadline: $deadline, + now: $now, + ); + } + + $failed = array_filter( + $states, + static fn (WorkflowState $state): bool => in_array( + $state, + [ + WorkflowState::Cancelled, + WorkflowState::Failed, + WorkflowState::TimedOut, + ], + true, + ), + ); + if ($failed !== []) { + $details = []; + foreach ($failed as $workflow => $state) { + $details[] = "{$workflow}: {$state->value}"; + } + + throw new RuntimeException( + 'CI did not succeed: ' . implode(', ', $details), + ); + } + if ( + count(array_filter( + $states, + static fn (WorkflowState $state): bool => $state + === WorkflowState::Succeeded, + )) === count(self::WORKFLOWS) + ) { + return; + } + + $this->sleeper->sleep( + min( + self::INTERVAL, + $deadline->remaining($this->clock->now()), + ), + ); + } + } + + public function publish( + string $tag, + string $target, + int $pull, + int $draft, + ): void { + $this->validateTag( + $tag, + $target, + 'disappeared before publication', + ); + $body = $this->body($target, $pull); + $release = $this->repository->draft($draft); + $this->validateDraft($release, $tag, $target, $pull, $body); + $preparation = new Preparation($tag, $target, $pull, $draft); + $published = $this->repository->publish($preparation); + $final = $this->repository->tag($tag); + $valid = $final !== null + && $final->name === $tag + && $final->target === $target + && $published->tag === $tag + && ! $published->draft + && ! $published->prerelease; + if (! $valid) { + $rolledBack = $this->repository->redraft($preparation); + if (! $rolledBack->draft) { + throw new RuntimeException( + "Release {$tag} has an unsafe public target and " + . 'could not be returned to draft', + ); + } + throw new RuntimeException( + "Release {$tag} target changed during publication; " + . 'the release was returned to draft', + ); + } + + TargetValidator::validateTag($final, $tag, $target); + TargetValidator::validateRelease( + new Release($published->tag, $final->target), + $tag, + $target, + ); + } + + private function createTag(string $target): string + { + $available = array_map( + static fn (Tag $tag): string => $tag->name, + $this->repository->tags(), + ); + $candidate = (string) Version::next($available); + while (true) { + $tag = $this->repository->createTag($candidate, $target); + if ($tag->name === $candidate && $tag->target === $target) { + TargetValidator::validateTag( + $tag, + $candidate, + $target, + ); + + return $candidate; + } + if ($tag->name !== $candidate) { + throw new RuntimeException( + "Tag creation returned {$tag->name} for {$candidate}", + ); + } + + $available = array_map( + static fn (Tag $availableTag): string => $availableTag->name, + $this->repository->tags(), + ); + $candidate = (string) Version::afterCollision( + $available, + $candidate, + ); + } + } + + private function validateTag( + string $name, + string $target, + string $missing, + ): void { + $tag = $this->repository->tag($name); + if ($tag === null) { + throw new RuntimeException("Tag {$name} {$missing}"); + } + TargetValidator::validateTag($tag, $name, $target); + } + + private function validateDraft( + Recovery $draft, + string $tag, + string $target, + int $pull, + string $body, + ): void { + $merge = new Merge( + number: $pull, + target: $target, + head: '', + parents: [], + base: 'main', + branch: 'automation/dependencies-recovery', + body: Merge::MARKER, + files: ['Dockerfile'], + state: 'merged', + ); + if ( + $draft->tag !== $tag + || $draft->body !== $body + || ! RecoverySelector::matches($draft, $merge) + ) { + throw new RuntimeException( + "Draft release {$draft->identifier} is unsafe", + ); + } + } + + private function body(string $target, int $pull): string + { + return sprintf(self::BODY, $target, $pull); + } +} diff --git a/.github/scripts/src/Automation/Preparation.php b/.github/scripts/src/Automation/Preparation.php new file mode 100644 index 0000000..0abe87b --- /dev/null +++ b/.github/scripts/src/Automation/Preparation.php @@ -0,0 +1,16 @@ + (string) $number, + 'base' => $base, + ]; + } +} diff --git a/.github/scripts/src/Automation/PullRequest.php b/.github/scripts/src/Automation/PullRequest.php new file mode 100644 index 0000000..9e77890 --- /dev/null +++ b/.github/scripts/src/Automation/PullRequest.php @@ -0,0 +1,19 @@ +head !== $expectedHead) { + throw new HeadChangedException( + "Pull request #{$pullRequest->number} head changed from " + . "{$expectedHead} to {$pullRequest->head}", + ); + } + if ($pullRequest->base !== $expectedBase) { + throw new HeadChangedException( + "Pull request #{$pullRequest->number} base changed from " + . "{$expectedBase} to {$pullRequest->base}", + ); + } + if ($pullRequest->baseBranch !== 'main') { + throw new PullRequestUnavailableException( + "Pull request #{$pullRequest->number} does not target main", + ); + } + if ($pullRequest->state !== 'open') { + throw new PullRequestUnavailableException( + "Pull request #{$pullRequest->number} is " + . $pullRequest->state, + ); + } + if ($pullRequest->review !== ReviewDecision::Approved) { + throw new ApprovalMissingException( + "Pull request #{$pullRequest->number} is not currently " + . 'approved', + ); + } + if (!$pullRequest->mergeable) { + throw new PullRequestUnavailableException( + "Pull request #{$pullRequest->number} is not currently " + . 'mergeable', + ); + } + } +} diff --git a/.github/scripts/src/Automation/Recovery.php b/.github/scripts/src/Automation/Recovery.php new file mode 100644 index 0000000..7eae43a --- /dev/null +++ b/.github/scripts/src/Automation/Recovery.php @@ -0,0 +1,19 @@ + $tags + * @param list $releases + * @param list $merges + */ + public static function select( + array $tags, + array $releases, + array $merges, + ): ?Candidate { + $released = []; + $published = []; + foreach ($releases as $release) { + if (!$release->draft) { + $released[$release->tag] = true; + } + if (!$release->draft && !$release->prerelease) { + $published[] = $release->tag; + } + } + + $stable = Version::stable($published); + $threshold = $stable === [] ? null : $stable[array_key_last($stable)]; + $candidates = []; + $targets = []; + foreach ($tags as $tag) { + $targets[$tag->target] = true; + $version = Version::parse($tag->name); + if ( + $version === null + || isset($released[$tag->name]) + || ( + $threshold !== null + && $version->compare($threshold) <= 0 + ) + ) { + continue; + } + + $evidence = array_values( + array_filter( + $merges, + static fn (Merge $merge): bool => ( + $merge->target === $tag->target + && MergeValidator::isAutomation($merge) + ), + ), + ); + if (count($evidence) !== 1) { + continue; + } + + $merge = $evidence[0]; + $drafts = array_values( + array_filter( + $releases, + static fn (Recovery $release): bool => ( + $release->tag === $tag->name + && self::matches($release, $merge) + ), + ), + ); + if (count($drafts) > 1) { + throw new RecoveryException( + "Multiple automation drafts exist for {$tag->name}", + ); + } + + $candidates[] = new Candidate( + tag: $tag->name, + target: $tag->target, + pull: $merge->number, + draft: $drafts === [] ? null : $drafts[0]->identifier, + ); + } + + foreach ($merges as $merge) { + if ( + !isset($targets[$merge->target]) + && MergeValidator::isAutomation($merge) + ) { + $candidates[] = new Candidate( + tag: null, + target: $merge->target, + pull: $merge->number, + draft: null, + ); + } + } + + $unique = []; + foreach ($candidates as $candidate) { + $key = implode( + "\0", + [ + $candidate->tag ?? '', + $candidate->target, + (string) $candidate->pull, + $candidate->draft === null + ? '' + : (string) $candidate->draft, + ], + ); + $unique[$key] = $candidate; + } + if (count($unique) > 1) { + $names = array_map( + static fn (Candidate $candidate): string => ( + $candidate->tag + ?? "pull request #{$candidate->pull}" + ), + array_values($unique), + ); + sort($names); + throw new RecoveryException( + 'Multiple dependency releases are recoverable: ' + . implode(', ', $names), + ); + } + + $candidate = $unique === [] + ? null + : array_values($unique)[0]; + $marked = array_filter( + $releases, + static fn (Recovery $release): bool => ( + $release->draft + && str_contains($release->body, Merge::MARKER) + ), + ); + if ( + $marked !== [] + && ( + $candidate === null + || self::hasDifferentDraft( + array_values($marked), + $candidate->draft, + ) + ) + ) { + throw new RecoveryException( + 'Unsafe dependency automation draft state exists', + ); + } + + return $candidate; + } + + public static function matches( + Recovery $release, + Merge $merge, + ): bool { + $lines = preg_split('/\R/', $release->body); + if ($lines === false) { + return false; + } + + return $release->draft + && !$release->prerelease + && self::count($lines, Merge::MARKER) === 1 + && self::count( + $lines, + "", + ) === 1 + && self::count( + $lines, + "", + ) === 1 + && in_array($release->pull, [0, $merge->number], true) + && $release->target === $merge->target; + } + + /** + * @param list $releases + */ + private static function hasDifferentDraft( + array $releases, + ?int $draft, + ): bool { + foreach ($releases as $release) { + if ($release->identifier !== $draft) { + return true; + } + } + + return false; + } + + /** + * @param list $lines + */ + private static function count(array $lines, string $expected): int + { + return count( + array_filter( + $lines, + static fn (string $line): bool => $line === $expected, + ), + ); + } +} diff --git a/.github/scripts/src/Automation/Release.php b/.github/scripts/src/Automation/Release.php new file mode 100644 index 0000000..0b5d2a4 --- /dev/null +++ b/.github/scripts/src/Automation/Release.php @@ -0,0 +1,14 @@ + + */ + public function tags(): array; + + public function releaseByTag(string $tag): ?Recovery; + + /** + * @param list $tags + * + * @return list + */ + public function releases(array $tags): array; + + /** + * @return list + */ + public function mergedPullRequests(): array; + + public function pullRequest(int $number): PullRequest; + + public function squashMerge( + int $number, + string $head, + string $base, + ): MergeResult; + + /** + * @return list + */ + public function commitParents(string $commit): array; + + public function tag(string $name): ?Tag; + + public function createTag(string $name, string $target): Tag; + + public function draft(int $identifier): Recovery; + + public function createDraft( + string $tag, + string $target, + int $pull, + string $body, + ): Recovery; + + public function publish(Preparation $preparation): Recovery; + + public function redraft(Preparation $preparation): Recovery; + + /** + * @return list + */ + public function runs( + string $workflow, + string $event, + string $head, + string $branch, + ): array; +} diff --git a/.github/scripts/src/Automation/Repository/GitHub.php b/.github/scripts/src/Automation/Repository/GitHub.php new file mode 100644 index 0000000..ffa11df --- /dev/null +++ b/.github/scripts/src/Automation/Repository/GitHub.php @@ -0,0 +1,1001 @@ +'; + + private const string PREFIX = 'automation/dependencies-'; + + public function __construct( + private Runner $runner, + private string $repository, + private string $version, + ) { + if (! preg_match('/^[^\/]+\/[^\/]+$/', $this->repository)) { + throw new RuntimeException( + "Invalid GitHub repository '{$this->repository}'", + ); + } + } + + /** + * @return list + */ + #[Override] + public function tags(): array + { + $tags = []; + foreach ( + $this->pages( + "repos/{$this->repository}/git/matching-refs/tags/", + ) as $item + ) { + $reference = $this->optionalString($item, 'ref'); + $object = $item['object'] ?? null; + if ( + ! str_starts_with($reference, 'refs/tags/') + || ! is_array($object) + || ($object['type'] ?? null) !== 'commit' + ) { + continue; + } + + $tags[] = new Tag( + substr($reference, strlen('refs/tags/')), + $this->requiredString($object, 'sha', 'tag target'), + ); + } + + return $tags; + } + + #[Override] + public function releaseByTag(string $tag): ?Recovery + { + $result = $this->api( + 'GET', + sprintf( + 'repos/%s/releases/tags/%s', + $this->repository, + rawurlencode($tag), + ), + check: false, + ); + if ($result->succeeded()) { + return $this->release( + $this->object($result->output, "release lookup for {$tag}"), + ); + } + + $error = $this->object($result->output, "release lookup for {$tag}"); + $status = $error['status'] ?? null; + if ( + (! is_int($status) && ! is_string($status)) + || (string) $status !== '404' + ) { + throw new RuntimeException("Release lookup failed for {$tag}"); + } + + return $this->graphQLRelease($tag); + } + + /** + * @param list $tags + * + * @return list + */ + #[Override] + public function releases(array $tags): array + { + $stable = []; + $versions = []; + foreach ($tags as $tag) { + $version = Version::parse($tag->name); + if ($version === null) { + continue; + } + + $stable[$tag->name] = $tag; + $versions[$tag->name] = $version; + } + + $listed = array_values( + array_filter( + $this->listedReleases(), + static fn (Recovery $release): bool => ( + ! $release->draft + && ! $release->prerelease + && isset($versions[$release->tag]) + ), + ), + ); + usort( + $listed, + static fn (Recovery $left, Recovery $right): int => ( + $versions[$right->tag]->compare($versions[$left->tag]) + ), + ); + + $releases = []; + $threshold = null; + foreach ($listed as $hint) { + $release = $this->releaseByTag($hint->tag); + if ($release === null) { + continue; + } + $this->assertTag($release, $hint->tag); + $releases[$release->tag] = $release; + if (! $release->draft && ! $release->prerelease) { + $threshold = $versions[$release->tag]; + break; + } + } + + $candidates = array_keys($stable); + usort( + $candidates, + static fn (string $left, string $right): int => ( + $versions[$right]->compare($versions[$left]) + ), + ); + foreach ($candidates as $tag) { + if ( + $threshold !== null + && $versions[$tag]->compare($threshold) <= 0 + ) { + continue; + } + + $release = $this->releaseByTag($tag); + if ($release === null) { + continue; + } + $this->assertTag($release, $tag); + $releases[$release->tag] = $release; + } + + return array_values($releases); + } + + /** + * @return list + */ + #[Override] + public function mergedPullRequests(): array + { + $merges = []; + $pulls = $this->pages( + "repos/{$this->repository}/pulls" + . '?state=closed&base=main&sort=updated&direction=desc', + ); + foreach ($pulls as $pull) { + $head = $pull['head'] ?? null; + $branch = is_array($head) + ? $this->optionalString($head, 'ref') + : ''; + $body = $this->optionalString($pull, 'body'); + if ( + ($pull['merged_at'] ?? null) === null + || ! str_contains($body, self::MARKER) + || ! str_starts_with($branch, self::PREFIX) + ) { + continue; + } + + $number = $this->requiredInteger($pull, 'number', 'pull request'); + $details = $this->pullPayload($number); + $commit = $details['mergeCommit'] ?? null; + $target = is_array($commit) + ? $this->optionalString($commit, 'oid') + : ''; + if ( + strtolower($this->optionalString($details, 'state')) !== 'merged' + || $target === '' + ) { + continue; + } + + $files = []; + foreach ( + $this->pages( + "repos/{$this->repository}/pulls/{$number}/files", + ) as $file + ) { + $files[] = $this->requiredString( + $file, + 'filename', + "pull request #{$number} file", + ); + } + + $merges[] = new Merge( + number: $number, + target: $target, + head: $this->optionalString($details, 'headRefOid'), + parents: $this->commitParents($target), + base: $this->optionalString($details, 'baseRefName'), + branch: $branch, + body: $body, + files: $files, + state: 'merged', + ); + } + + return $merges; + } + + #[Override] + public function pullRequest(int $number): PullRequest + { + $pull = $this->pullPayload($number); + $review = ReviewDecision::tryFrom( + strtolower($this->optionalString($pull, 'reviewDecision')), + ) ?? ReviewDecision::ReviewRequired; + + return new PullRequest( + number: $this->requiredInteger($pull, 'number', 'pull request'), + head: $this->requiredString($pull, 'headRefOid', 'pull request'), + base: $this->requiredString($pull, 'baseRefOid', 'pull request'), + baseBranch: $this->requiredString( + $pull, + 'baseRefName', + 'pull request', + ), + state: strtolower( + $this->requiredString($pull, 'state', 'pull request'), + ), + review: $review, + mergeable: ($pull['mergeable'] ?? null) === 'MERGEABLE', + ); + } + + #[Override] + public function squashMerge( + int $number, + string $head, + string $base, + ): MergeResult { + $this->runner->run( + [ + 'gh', + 'pr', + 'merge', + (string) $number, + '--repo', + $this->repository, + '--squash', + '--match-head-commit', + $head, + ], + check: false, + ); + + $pull = $this->pullPayload($number); + $merge = $pull['mergeCommit'] ?? null; + $commit = is_array($merge) + ? $this->optionalString($merge, 'oid') + : ''; + $commit = $commit === '' ? null : $commit; + + return new MergeResult( + head: $this->optionalString($pull, 'headRefOid'), + state: strtolower($this->optionalString($pull, 'state')), + commit: $commit, + parents: $commit === null ? [] : $this->commitParents($commit), + ); + } + + /** + * @return list + */ + #[Override] + public function commitParents(string $commit): array + { + $result = $this->api( + 'GET', + "repos/{$this->repository}/commits/{$commit}", + ); + $payload = $this->object( + $result->output, + "commit {$commit}", + ); + $parents = $payload['parents'] ?? null; + if (! is_array($parents) || ! array_is_list($parents)) { + throw new RuntimeException("Commit {$commit} has invalid parents"); + } + + $decoded = []; + foreach ($parents as $parent) { + if (! is_array($parent)) { + throw new RuntimeException( + "Commit {$commit} has invalid parents", + ); + } + $decoded[] = $this->requiredString( + $parent, + 'sha', + "commit {$commit} parent", + ); + } + + return $decoded; + } + + #[Override] + public function tag(string $name): ?Tag + { + $result = $this->api( + 'GET', + sprintf( + 'repos/%s/git/ref/tags/%s', + $this->repository, + rawurlencode($name), + ), + check: false, + ); + if (! $result->succeeded()) { + return null; + } + + $payload = $this->object($result->output, "tag {$name}"); + $target = $payload['object'] ?? null; + if (! is_array($target) || ($target['type'] ?? null) !== 'commit') { + throw new RuntimeException("Tag {$name} is not lightweight"); + } + + return new Tag( + $this->removePrefix( + $this->requiredString($payload, 'ref', "tag {$name}"), + 'refs/tags/', + ), + $this->requiredString($target, 'sha', "tag {$name} target"), + ); + } + + #[Override] + public function createTag(string $name, string $target): Tag + { + $result = $this->api( + 'POST', + "repos/{$this->repository}/git/refs", + [ + ['-f', "ref=refs/tags/{$name}"], + ['-f', "sha={$target}"], + ], + check: false, + ); + $tag = $this->tag($name); + if ($tag !== null) { + return $tag; + } + if (! $result->succeeded()) { + throw new RuntimeException("Failed to create tag {$name}"); + } + + throw new RuntimeException("Tag {$name} is missing after creation"); + } + + #[Override] + public function draft(int $identifier): Recovery + { + $result = $this->api( + 'GET', + "repos/{$this->repository}/releases/{$identifier}", + ); + + return $this->release( + $this->object($result->output, "release {$identifier}"), + ); + } + + #[Override] + public function createDraft( + string $tag, + string $target, + int $pull, + string $body, + ): Recovery { + $existing = $this->releaseByTag($tag); + if ($existing !== null) { + if (! $existing->draft) { + throw new RuntimeException("Release {$tag} is already published"); + } + + return $this->assertDraft( + $existing, + $tag, + $target, + $pull, + $body, + ); + } + + $result = $this->api( + 'POST', + "repos/{$this->repository}/releases", + [ + ['-f', "tag_name={$tag}"], + ['-f', "target_commitish={$target}"], + ['-f', "name={$tag}"], + ['-f', "body={$body}"], + ['-F', 'draft=true'], + ['-F', 'prerelease=false'], + ['-F', 'generate_release_notes=true'], + ], + check: false, + ); + if ($result->succeeded()) { + return $this->assertDraft( + $this->release( + $this->object( + $result->output, + "draft release {$tag}", + ), + pull: $pull, + ), + $tag, + $target, + $pull, + $body, + ); + } + + $concurrent = $this->releaseByTag($tag); + if ($concurrent === null) { + throw new RuntimeException( + "Failed to create draft release {$tag}", + ); + } + if (! $concurrent->draft) { + throw new RuntimeException( + "Release {$tag} was published concurrently", + ); + } + + return $this->assertDraft( + $concurrent, + $tag, + $target, + $pull, + $body, + ); + } + + #[Override] + public function publish(Preparation $preparation): Recovery + { + $result = $this->api( + 'PATCH', + "repos/{$this->repository}/releases/{$preparation->draft}", + [ + ['-F', 'draft=false'], + ['-F', 'prerelease=false'], + ['-f', 'make_latest=true'], + ], + check: false, + ); + if (! $result->succeeded() || trim($result->output) === '') { + return $this->draft($preparation->draft); + } + + return $this->release( + $this->object( + $result->output, + "published release {$preparation->tag}", + ), + pull: $preparation->pull, + ); + } + + #[Override] + public function redraft(Preparation $preparation): Recovery + { + $this->api( + 'PATCH', + "repos/{$this->repository}/releases/{$preparation->draft}", + [['-F', 'draft=true']], + check: false, + ); + + return $this->draft($preparation->draft); + } + + /** + * @return list + */ + #[Override] + public function runs( + string $workflow, + string $event, + string $head, + string $branch, + ): array { + $result = $this->api( + 'GET', + "repos/{$this->repository}/actions/workflows/{$workflow}/runs", + [ + ['-f', "branch={$branch}"], + ['-f', "head_sha={$head}"], + ['-f', "event={$event}"], + ['-f', 'per_page=100'], + ], + ); + $payload = $this->object( + $result->output, + "workflow {$workflow} runs", + ); + $runs = $payload['workflow_runs'] ?? null; + if (! is_array($runs) || ! array_is_list($runs)) { + throw new RuntimeException( + "Workflow {$workflow} runs are invalid", + ); + } + + $decoded = []; + foreach ($runs as $run) { + if (! is_array($run)) { + throw new RuntimeException( + "Workflow {$workflow} run is invalid", + ); + } + $conclusion = $run['conclusion'] ?? null; + if ($conclusion !== null && ! is_string($conclusion)) { + throw new RuntimeException( + "Workflow {$workflow} run conclusion is invalid", + ); + } + $decoded[] = new Run( + identifier: $this->requiredInteger( + $run, + 'id', + "workflow {$workflow} run", + ), + workflow: $this->requiredString( + $run, + 'name', + "workflow {$workflow} run", + ), + event: $this->requiredString( + $run, + 'event', + "workflow {$workflow} run", + ), + head: $this->requiredString( + $run, + 'head_sha', + "workflow {$workflow} run", + ), + branch: $this->requiredString( + $run, + 'head_branch', + "workflow {$workflow} run", + ), + created: new DateTimeImmutable( + $this->requiredString( + $run, + 'created_at', + "workflow {$workflow} run", + ), + ), + attempt: $this->requiredInteger( + $run, + 'run_attempt', + "workflow {$workflow} run", + ), + status: $this->requiredString( + $run, + 'status', + "workflow {$workflow} run", + ), + conclusion: $conclusion, + ); + } + + return $decoded; + } + + /** + * @param list $fields + */ + private function api( + string $method, + string $endpoint, + array $fields = [], + bool $check = true, + ): Result { + $command = [ + 'gh', + 'api', + '-X', + $method, + $endpoint, + '-H', + "X-GitHub-Api-Version: {$this->version}", + ]; + foreach ($fields as [$kind, $value]) { + array_push($command, $kind, $value); + } + + return $this->runner->run($command, $check); + } + + /** + * @return list> + */ + private function pages(string $endpoint): array + { + $result = $this->runner->run([ + 'gh', + 'api', + '--paginate', + '--slurp', + '-X', + 'GET', + $endpoint, + '-H', + "X-GitHub-Api-Version: {$this->version}", + '-f', + 'per_page=100', + ]); + try { + $pages = json_decode( + $result->output, + true, + 512, + JSON_THROW_ON_ERROR, + ); + } catch (JsonException $exception) { + throw new RuntimeException( + "Invalid paginated GitHub response for {$endpoint}", + previous: $exception, + ); + } + if (! is_array($pages) || ! array_is_list($pages)) { + throw new RuntimeException( + "Invalid paginated GitHub response for {$endpoint}", + ); + } + + $items = []; + foreach ($pages as $page) { + if (! is_array($page) || ! array_is_list($page)) { + throw new RuntimeException( + "Invalid paginated GitHub response for {$endpoint}", + ); + } + foreach ($page as $item) { + if (is_array($item) && ! array_is_list($item)) { + $items[] = $this->dictionary( + $item, + "paginated GitHub item for {$endpoint}", + ); + } + } + } + + return $items; + } + + /** + * @return list + */ + private function listedReleases(): array + { + $releases = []; + foreach ( + $this->pages("repos/{$this->repository}/releases") as $item + ) { + $releases[] = $this->release($item); + } + + return $releases; + } + + private function graphQLRelease(string $tag): ?Recovery + { + [$owner, $name] = explode('/', $this->repository, 2); + $query = 'query($owner:String!,$name:String!,$tag:String!){' + . 'repository(owner:$owner,name:$name){' + . 'release(tagName:$tag){' + . 'databaseId tagName isDraft isPrerelease description ' + . 'tagCommit{oid}' + . '}}}'; + $result = $this->api( + 'POST', + 'graphql', + [ + ['-f', "owner={$owner}"], + ['-f', "name={$name}"], + ['-f', "tag={$tag}"], + ['-f', "query={$query}"], + ], + ); + $payload = $this->object( + $result->output, + "GraphQL release lookup for {$tag}", + ); + $errors = $payload['errors'] ?? null; + if (is_array($errors) && $errors !== []) { + throw new RuntimeException( + "GitHub GraphQL release lookup failed for {$tag}", + ); + } + $data = $payload['data'] ?? null; + if (! is_array($data)) { + throw new RuntimeException( + "Release lookup for {$tag} is invalid", + ); + } + $repository = $data['repository'] ?? null; + if (! is_array($repository)) { + throw new RuntimeException( + "Release lookup for {$tag} is invalid", + ); + } + $release = $repository['release'] ?? null; + if ($release === null) { + return null; + } + if (! is_array($release)) { + throw new RuntimeException( + "Release lookup for {$tag} is invalid", + ); + } + + $commit = $release['tagCommit'] ?? null; + $target = is_array($commit) + ? $this->optionalString($commit, 'oid') + : ''; + + return new Recovery( + identifier: $this->requiredInteger( + $release, + 'databaseId', + "release {$tag}", + ), + tag: $this->requiredString($release, 'tagName', "release {$tag}"), + target: $target, + pull: $this->pullFromBody( + $this->optionalString($release, 'description'), + ), + draft: $this->requiredBoolean( + $release, + 'isDraft', + "release {$tag}", + ), + prerelease: $this->requiredBoolean( + $release, + 'isPrerelease', + "release {$tag}", + ), + body: $this->optionalString($release, 'description'), + ); + } + + /** + * @return array + */ + private function pullPayload(int $number): array + { + $result = $this->runner->run([ + 'gh', + 'pr', + 'view', + (string) $number, + '--repo', + $this->repository, + '--json', + ( + 'baseRefName,baseRefOid,headRefOid,mergeCommit,' + . 'mergeable,number,reviewDecision,state' + ), + '--jq', + '.', + ]); + + return $this->object( + $result->output, + "pull request #{$number}", + ); + } + + /** + * @param array $payload + */ + private function release(array $payload, ?int $pull = null): Recovery + { + $body = $this->optionalString($payload, 'body'); + + return new Recovery( + identifier: $this->requiredInteger($payload, 'id', 'release'), + tag: $this->requiredString($payload, 'tag_name', 'release'), + target: $this->requiredString( + $payload, + 'target_commitish', + 'release', + ), + pull: $pull ?? $this->pullFromBody($body), + draft: $this->requiredBoolean($payload, 'draft', 'release'), + prerelease: $this->requiredBoolean( + $payload, + 'prerelease', + 'release', + ), + body: $body, + ); + } + + private function assertDraft( + Recovery $release, + string $tag, + string $target, + int $pull, + string $body, + ): Recovery { + if ( + $release->tag !== $tag + || $release->target !== $target + || $release->pull !== $pull + || ! $release->draft + || $release->prerelease + || $release->body !== $body + ) { + throw new RuntimeException( + "Draft release {$release->identifier} is unsafe", + ); + } + + return $release; + } + + private function assertTag(Recovery $release, string $expected): void + { + if ($release->tag !== $expected) { + throw new RuntimeException( + "Release lookup for {$expected} returned {$release->tag}", + ); + } + } + + private function pullFromBody(string $body): int + { + if ( + preg_match( + '//', + $body, + $matches, + ) !== 1 + ) { + return 0; + } + + return (int) $matches[1]; + } + + /** + * @return array + */ + private function object(string $json, string $label): array + { + try { + $payload = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new RuntimeException( + ucfirst($label) . ' is invalid', + previous: $exception, + ); + } + if (! is_array($payload) || array_is_list($payload)) { + throw new RuntimeException(ucfirst($label) . ' is invalid'); + } + + return $this->dictionary($payload, $label); + } + + /** + * @param array $payload + * + * @return array + */ + private function dictionary(array $payload, string $label): array + { + $decoded = []; + foreach ($payload as $key => $value) { + if (! is_string($key)) { + throw new RuntimeException(ucfirst($label) . ' is invalid'); + } + $decoded[$key] = $value; + } + + return $decoded; + } + + /** + * @param array $payload + */ + private function optionalString(array $payload, string $key): string + { + $value = $payload[$key] ?? ''; + if (! is_string($value)) { + throw new RuntimeException("GitHub field {$key} is invalid"); + } + + return $value; + } + + /** + * @param array $payload + */ + private function requiredString( + array $payload, + string $key, + string $label, + ): string { + $value = $payload[$key] ?? null; + if (! is_string($value) || $value === '') { + throw new RuntimeException(ucfirst($label) . " {$key} is invalid"); + } + + return $value; + } + + /** + * @param array $payload + */ + private function requiredInteger( + array $payload, + string $key, + string $label, + ): int { + $value = $payload[$key] ?? null; + if (! is_int($value)) { + throw new RuntimeException(ucfirst($label) . " {$key} is invalid"); + } + + return $value; + } + + /** + * @param array $payload + */ + private function requiredBoolean( + array $payload, + string $key, + string $label, + ): bool { + $value = $payload[$key] ?? null; + if (! is_bool($value)) { + throw new RuntimeException(ucfirst($label) . " {$key} is invalid"); + } + + return $value; + } + + private function removePrefix(string $value, string $prefix): string + { + return str_starts_with($value, $prefix) + ? substr($value, strlen($prefix)) + : $value; + } +} diff --git a/.github/scripts/src/Automation/ReviewDecision.php b/.github/scripts/src/Automation/ReviewDecision.php new file mode 100644 index 0000000..54bf6a4 --- /dev/null +++ b/.github/scripts/src/Automation/ReviewDecision.php @@ -0,0 +1,12 @@ + $runs + */ + public static function select( + array $runs, + string $workflow, + string $event, + string $head, + string $branch, + DateTimeImmutable $created, + ): ?Run { + $selected = null; + foreach ($runs as $run) { + if ($run->attempt < 1) { + throw new InvalidArgumentException( + 'Workflow run attempt must be positive', + ); + } + if ( + $run->workflow !== $workflow + || $run->event !== $event + || $run->head !== $head + || $run->branch !== $branch + || $run->created < $created + ) { + continue; + } + if ( + $selected === null + || self::isNewer($run, $selected) + ) { + $selected = $run; + } + } + + return $selected; + } + + /** + * @param list $runs + */ + public static function state( + array $runs, + string $workflow, + string $event, + string $head, + string $branch, + DateTimeImmutable $created, + Deadline $deadline, + DateTimeImmutable $now, + ): WorkflowState { + $run = self::select( + $runs, + workflow: $workflow, + event: $event, + head: $head, + branch: $branch, + created: $created, + ); + if ($run === null) { + return $deadline->expired($now) + ? WorkflowState::TimedOut + : WorkflowState::Missing; + } + if ($run->status !== 'completed') { + return $deadline->expired($now) + ? WorkflowState::TimedOut + : WorkflowState::Pending; + } + + return match ($run->conclusion) { + 'success' => WorkflowState::Succeeded, + 'cancelled' => WorkflowState::Cancelled, + 'timed_out' => WorkflowState::TimedOut, + default => WorkflowState::Failed, + }; + } + + private static function isNewer(Run $candidate, Run $selected): bool + { + if ($candidate->created != $selected->created) { + return $candidate->created > $selected->created; + } + if ($candidate->identifier !== $selected->identifier) { + return $candidate->identifier > $selected->identifier; + } + + return $candidate->attempt > $selected->attempt; + } +} diff --git a/.github/scripts/src/Automation/Sleeper.php b/.github/scripts/src/Automation/Sleeper.php new file mode 100644 index 0000000..722ed98 --- /dev/null +++ b/.github/scripts/src/Automation/Sleeper.php @@ -0,0 +1,10 @@ + 0) { + \sleep($seconds); + } + } +} diff --git a/.github/scripts/src/Automation/Tag.php b/.github/scripts/src/Automation/Tag.php new file mode 100644 index 0000000..0c981d7 --- /dev/null +++ b/.github/scripts/src/Automation/Tag.php @@ -0,0 +1,14 @@ +name !== $expectedName) { + throw new TargetMismatchException( + "Expected tag {$expectedName}, found {$tag->name}", + ); + } + if ($tag->target !== $expectedTarget) { + throw new TargetMismatchException( + "Tag {$tag->name} targets {$tag->target}, " + . "expected {$expectedTarget}", + ); + } + } + + public static function validateRelease( + Release $release, + string $expectedTag, + string $expectedTarget, + ): void { + if ($release->tag !== $expectedTag) { + throw new TargetMismatchException( + "Expected release for {$expectedTag}, " + . "found {$release->tag}", + ); + } + if ($release->target !== $expectedTarget) { + throw new TargetMismatchException( + "Release {$release->tag} targets {$release->target}, " + . "expected {$expectedTarget}", + ); + } + } +} diff --git a/.github/scripts/src/Automation/Version.php b/.github/scripts/src/Automation/Version.php new file mode 100644 index 0000000..0417a67 --- /dev/null +++ b/.github/scripts/src/Automation/Version.php @@ -0,0 +1,225 @@ + $tags + * + * @return list + */ + public static function stable(iterable $tags): array + { + $versions = []; + foreach ($tags as $tag) { + $version = self::parse($tag); + if ($version !== null) { + $versions[(string) $version] = $version; + } + } + + $versions = array_values($versions); + usort( + $versions, + static fn (self $left, self $right): int => $left->compare( + $right, + ), + ); + + return $versions; + } + + /** + * @param iterable $tags + */ + public static function latest(iterable $tags): self + { + $versions = self::stable($tags); + if ($versions === []) { + throw new VersionMissingException( + 'No stable remote version tag exists', + ); + } + + return $versions[array_key_last($versions)]; + } + + /** + * @param iterable $tags + */ + public static function next(iterable $tags): self + { + return self::latest($tags)->nextPatch(); + } + + /** + * @param iterable $tags + * @param iterable $releases + */ + public static function unreleased( + iterable $tags, + iterable $releases, + ): ?self { + $tagged = self::stable($tags); + if ($tagged === []) { + return null; + } + + $published = self::stable($releases); + $released = []; + foreach ($published as $version) { + $released[(string) $version] = true; + } + $threshold = $published === [] + ? null + : $published[array_key_last($published)]; + $candidate = null; + foreach ($tagged as $version) { + if ( + isset($released[(string) $version]) + || ( + $threshold !== null + && $version->compare($threshold) <= 0 + ) + ) { + continue; + } + if ( + $candidate === null + || $version->compare($candidate) > 0 + ) { + $candidate = $version; + } + } + + return $candidate; + } + + /** + * @param iterable $tags + * @param iterable $releases + */ + public static function candidate( + iterable $tags, + iterable $releases, + ): self { + $values = is_array($tags) + ? array_values($tags) + : iterator_to_array($tags, false); + $unreleased = self::unreleased($values, $releases); + + return $unreleased ?? self::next($values); + } + + /** + * @param iterable $tags + */ + public static function afterCollision( + iterable $tags, + string $collision, + ): self { + $collided = self::parse($collision); + if ($collided === null) { + throw new VersionInvalidException( + "Collision tag '{$collision}' is not a stable version", + ); + } + + $values = is_array($tags) + ? array_values($tags) + : iterator_to_array($tags, false); + $values[] = (string) $collided; + + return self::next($values); + } + + public function nextPatch(): self + { + return new self( + $this->major, + $this->minor, + self::increment($this->patch), + ); + } + + public function compare(self $other): int + { + foreach ([ + [$this->major, $other->major], + [$this->minor, $other->minor], + [$this->patch, $other->patch], + ] as [$current, $candidate]) { + $comparison = strlen($current) <=> strlen($candidate); + if ($comparison !== 0) { + return $comparison; + } + + $comparison = strcmp($current, $candidate) <=> 0; + if ($comparison !== 0) { + return $comparison; + } + } + + return 0; + } + + #[Override] + public function __toString(): string + { + return "{$this->major}.{$this->minor}.{$this->patch}"; + } + + private static function increment(string $value): string + { + $incremented = $value; + for ($index = strlen($incremented) - 1; $index >= 0; $index--) { + if ($incremented[$index] === '9') { + $incremented[$index] = '0'; + + continue; + } + + $incremented[$index] = chr(ord($incremented[$index]) + 1); + + return $incremented; + } + + return '1' . $incremented; + } +} diff --git a/.github/scripts/src/Automation/VersionInvalidException.php b/.github/scripts/src/Automation/VersionInvalidException.php new file mode 100644 index 0000000..91928da --- /dev/null +++ b/.github/scripts/src/Automation/VersionInvalidException.php @@ -0,0 +1,9 @@ + $values + */ + public function __construct( + private array $values, + ) { + foreach ($values as $name => $value) { + if (preg_match('/\A[A-Za-z_][A-Za-z0-9_]*\z/D', $name) !== 1) { + throw new InvalidArgumentException( + "Invalid workflow output name '{$name}'", + ); + } + if (str_contains($value, "\n") || str_contains($value, "\r")) { + throw new InvalidArgumentException( + "Workflow output '{$name}' must fit on one line", + ); + } + } + } + + public static function recovery(?Candidate $candidate): self + { + if ($candidate === null) { + return new self(['pending' => 'false']); + } + + return new self([ + 'pending' => 'true', + 'tag' => $candidate->tag ?? '', + 'head' => $candidate->target, + 'pull' => (string) $candidate->pull, + 'draft' => $candidate->draft === null + ? '' + : (string) $candidate->draft, + ]); + } + + public static function preparation(Preparation $preparation): self + { + return new self([ + 'tag' => $preparation->tag, + 'head' => $preparation->target, + 'pull' => (string) $preparation->pull, + 'draft' => (string) $preparation->draft, + ]); + } + + public static function merge(string $head): self + { + return new self(['head' => $head]); + } + + /** + * @return array + */ + public function values(): array + { + return $this->values; + } + + public function render(): string + { + $lines = []; + foreach ($this->values as $name => $value) { + $lines[] = "{$name}={$value}"; + } + + return $lines === [] ? '' : implode(PHP_EOL, $lines) . PHP_EOL; + } +} diff --git a/.github/scripts/src/Automation/WorkflowState.php b/.github/scripts/src/Automation/WorkflowState.php new file mode 100644 index 0000000..f292d86 --- /dev/null +++ b/.github/scripts/src/Automation/WorkflowState.php @@ -0,0 +1,15 @@ + $command + */ + #[Override] + public function __construct( + public readonly array $command, + public readonly ?Result $result = null, + ?string $message = null, + ?Throwable $previous = null, + ) { + parent::__construct( + $message ?? self::describe($command, $result), + $result->code ?? 0, + $previous, + ); + } + + /** + * @param list $command + */ + private static function describe(array $command, ?Result $result): string + { + $message = 'Command failed: ' . implode(' ', $command); + $detail = trim($result->error ?? ''); + + return $detail === '' ? $message : "{$message}: {$detail}"; + } +} diff --git a/.github/scripts/src/Command/Failure.php b/.github/scripts/src/Command/Failure.php new file mode 100644 index 0000000..2a53a9a --- /dev/null +++ b/.github/scripts/src/Command/Failure.php @@ -0,0 +1,20 @@ + $command + */ + public function __construct( + array $command, + ?Result $result = null, + ?string $message = null, + ?Throwable $previous = null, + ); +} diff --git a/.github/scripts/src/Command/Process.php b/.github/scripts/src/Command/Process.php new file mode 100644 index 0000000..f7c370e --- /dev/null +++ b/.github/scripts/src/Command/Process.php @@ -0,0 +1,94 @@ +|null $environment */ + public function __construct(private ?string $directory = null, private ?array $environment = null) + { + } + + /** @param list $command */ + #[Override] + public function run(array $command, bool $check = true): Result + { + if ($command === [] || in_array('', $command, true)) { + throw new Exception($command, message: 'A command must contain non-empty arguments'); + } + + if ($this->environment !== null || $this->directory !== null) { + return $this->runWithEnvironment($command, $check); + } + + $result = $this->executeConsole($command); + if ($check && ! $result->succeeded()) { + throw new Exception($command, $result); + } + + return $result; + } + + /** @param list $command */ + private function executeConsole(array $command): Result + { + $stdout = ''; + $stderr = ''; + $code = Console::execute($command, '', $stdout, $stderr); + + return new Result($code, $stdout, $stderr); + } + + /** @param list $command */ + private function runWithEnvironment(array $command, bool $check): Result + { + if ($this->directory !== null && ! is_dir($this->directory)) { + throw new Exception($command, message: "Unable to start command in directory: {$this->directory}"); + } + + $input = tmpfile(); + $output = tmpfile(); + $error = tmpfile(); + if ($input === false || $output === false || $error === false) { + foreach ([$input, $output, $error] as $stream) { + if (is_resource($stream)) { + fclose($stream); + } + } + throw new Exception($command, message: 'Unable to allocate command output streams'); + } + + try { + $pipes = []; + $process = proc_open($command, [0 => $input, 1 => $output, 2 => $error], $pipes, $this->directory, $this->environment, ['bypass_shell' => true]); + if (! is_resource($process)) { + $message = $this->directory === null + ? 'Unable to start command' + : "Unable to start command in directory: {$this->directory}"; + throw new Exception($command, message: $message); + } + $code = proc_close($process); + rewind($output); + rewind($error); + $stdout = stream_get_contents($output); + $stderr = stream_get_contents($error); + if ($stdout === false || $stderr === false) { + throw new Exception($command, message: 'Unable to read command output'); + } + $result = new Result($code, $stdout, $stderr); + if ($check && ! $result->succeeded()) { + throw new Exception($command, $result); + } + return $result; + } finally { + fclose($input); + fclose($output); + fclose($error); + } + } +} diff --git a/.github/scripts/src/Command/Result.php b/.github/scripts/src/Command/Result.php new file mode 100644 index 0000000..bf2f1f2 --- /dev/null +++ b/.github/scripts/src/Command/Result.php @@ -0,0 +1,20 @@ +code === 0; + } +} diff --git a/.github/scripts/src/Command/Runner.php b/.github/scripts/src/Command/Runner.php new file mode 100644 index 0000000..5768cd6 --- /dev/null +++ b/.github/scripts/src/Command/Runner.php @@ -0,0 +1,13 @@ + $command + */ + public function run(array $command, bool $check = true): Result; +} diff --git a/.github/scripts/src/Dependency/Application.php b/.github/scripts/src/Dependency/Application.php new file mode 100644 index 0000000..bc381b9 --- /dev/null +++ b/.github/scripts/src/Dependency/Application.php @@ -0,0 +1,58 @@ +dockerfile->pins($content, $this->catalog); + $selected = [$this->resolver->digest()]; + + foreach ($this->catalog->dependencies() as $index => $dependency) { + $releases = $this->resolver->releases($dependency); + $selected[] = $this->selector->select( + $pins[$index + 1]->current, + $releases, + ); + } + + $changes = []; + foreach ($pins as $index => $pin) { + $changes[] = new Change( + $pin->name, + $pin->current, + $selected[$index], + ); + } + + return new Plan( + $this->dockerfile->replace($content, $pins, $selected), + $changes, + ); + } +} diff --git a/.github/scripts/src/Dependency/Catalog.php b/.github/scripts/src/Dependency/Catalog.php new file mode 100644 index 0000000..ec780c3 --- /dev/null +++ b/.github/scripts/src/Dependency/Catalog.php @@ -0,0 +1,102 @@ + $dependencies + */ + private function __construct( + private array $dependencies, + ) { + } + + public static function create(): self + { + return new self([ + new Dependency( + 'brotli', + 'PHP_BROTLI_VERSION', + new Git('https://github.com/kjdev/php-ext-brotli.git'), + ), + new Dependency( + 'imagick', + 'PHP_IMAGICK_VERSION', + new Git('https://github.com/imagick/imagick'), + ), + new Dependency( + 'lz4', + 'PHP_LZ4_VERSION', + new Git('https://github.com/kjdev/php-ext-lz4.git'), + ), + new Dependency( + 'maxminddb', + 'PHP_MAXMINDDB_VERSION', + new Git('https://github.com/maxmind/MaxMind-DB-Reader-php.git'), + ), + new Dependency( + 'mongodb', + 'PHP_MONGODB_VERSION', + new Git('https://github.com/mongodb/mongo-php-driver.git'), + ), + new Dependency( + 'protobuf', + 'PHP_PROTOBUF_VERSION', + new PECL(self::PECL_RELEASES), + ), + new Dependency( + 'redis', + 'PHP_REDIS_VERSION', + new Git('https://github.com/phpredis/phpredis.git'), + ), + new Dependency( + 'scrypt', + 'PHP_SCRYPT_VERSION', + new Git('https://github.com/DomBlack/php-scrypt.git'), + ), + new Dependency( + 'snappy', + 'PHP_SNAPPY_VERSION', + new Git('https://github.com/kjdev/php-ext-snappy.git'), + ), + new Dependency( + 'swoole', + 'PHP_SWOOLE_VERSION', + new Git('https://github.com/swoole/swoole-src.git'), + ), + new Dependency( + 'xdebug', + 'PHP_XDEBUG_VERSION', + new Git('https://github.com/xdebug/xdebug'), + ), + new Dependency( + 'yaml', + 'PHP_YAML_VERSION', + new Git('https://github.com/php/pecl-file_formats-yaml'), + ), + new Dependency( + 'zstd', + 'PHP_ZSTD_VERSION', + new Git('https://github.com/kjdev/php-ext-zstd.git'), + ), + ]); + } + + /** + * @return list + */ + public function dependencies(): array + { + return $this->dependencies; + } +} diff --git a/.github/scripts/src/Dependency/Change.php b/.github/scripts/src/Dependency/Change.php new file mode 100644 index 0000000..2126ceb --- /dev/null +++ b/.github/scripts/src/Dependency/Change.php @@ -0,0 +1,20 @@ +current !== $this->latest; + } +} diff --git a/.github/scripts/src/Dependency/Console.php b/.github/scripts/src/Dependency/Console.php new file mode 100644 index 0000000..e492045 --- /dev/null +++ b/.github/scripts/src/Dependency/Console.php @@ -0,0 +1,79 @@ + $arguments + */ + public function execute(array $arguments): string + { + if ($arguments === ['--help']) { + return self::USAGE; + } + if (in_array('--help', $arguments, true)) { + throw new UsageException( + '--help cannot be combined with other arguments', + ); + } + + $normalized = ['dependencies.php', 'update']; + for ($index = 0; $index < count($arguments); ++$index) { + $argument = $arguments[$index]; + if ($argument === '--dry-run') { + $normalized[] = '--dry-run=true'; + } elseif ($argument === '--dockerfile') { + $path = $arguments[++$index] ?? ''; + if ($path === '') { + throw new UsageException('--dockerfile requires a path'); + } + $normalized[] = '--dockerfile=' . $path; + } elseif (str_starts_with($argument, '--dockerfile=')) { + $path = substr($argument, strlen('--dockerfile=')); + if ($path === '') { + throw new UsageException('--dockerfile requires a path'); + } + $normalized[] = $argument; + } else { + throw new UsageException("Unknown dependency updater argument '{$argument}'"); + } + } + + $cli = new CLI(args: $normalized); + $result = ''; + $failure = null; + $cli->task('update')->action(function () use ($cli, &$result): void { + $args = $cli->getArgs(); + $dockerfile = is_string($args['dockerfile'] ?? null) + ? $args['dockerfile'] + : $this->dockerfile; + $dryRun = ($args['dry-run'] ?? 'false') === 'true'; + $result = $this->reporter->render($this->updater->update($dockerfile, $dryRun)); + }); + $cli->error()->action(function () use ($cli, &$failure): void { + $failure = $cli->getResource('error'); + }); + $cli->run(); + + if ($failure instanceof Throwable) { + throw $failure; + } + + return $result; + } +} diff --git a/.github/scripts/src/Dependency/Dependency.php b/.github/scripts/src/Dependency/Dependency.php new file mode 100644 index 0000000..557db35 --- /dev/null +++ b/.github/scripts/src/Dependency/Dependency.php @@ -0,0 +1,15 @@ + + */ + public function pins(string $content, Catalog $catalog): array + { + $expected = []; + foreach ($catalog->dependencies() as $dependency) { + $expected[$dependency->variable] = true; + } + + $declared = []; + $lines = $this->matches( + '/^[ \t]*(?:(?:ARG|ENV)[ \t]+[^\r\n]*|PHP_[A-Za-z0-9_]+_VERSION[^\r\n]*)$/m', + $content, + ); + foreach ($lines as $line) { + $variables = $this->matches( + '/(?single( + '/^[ \t]*ARG[ \t]+BASE_IMAGE="([^"\r\n]+)"[ \t]*$/m', + $content, + 'ARG BASE_IMAGE', + ); + $value = $image[1][0]; + $base = preg_quote(Catalog::BASE, '/'); + $matched = preg_match( + "/\\A{$base}@(" . self::DIGEST_PATTERN . ')\\z/', + $value, + $digest, + PREG_OFFSET_CAPTURE, + ); + if ($matched !== 1) { + throw new Exception( + 'ARG BASE_IMAGE must pin ' . Catalog::BASE + . ' to a lowercase sha256 digest', + ); + } + + $start = $image[1][1] + $digest[1][1]; + $pins = [ + new Pin( + Catalog::BASE, + $digest[1][0], + $start, + $start + strlen($digest[1][0]), + ), + ]; + + foreach ($catalog->dependencies() as $dependency) { + $variable = preg_quote($dependency->variable, '/'); + $match = $this->single( + "/^[ \t]*(?:ENV[ \t]+)?{$variable}=\"([^\"\\r\\n]+)\"[ \t]*(?:\\\\)?[ \t]*$/m", + $content, + $dependency->variable, + ); + $current = $match[1][0]; + if (Version::parse($current) === null) { + throw new Exception( + "{$dependency->variable} must be an exact stable " + . 'v?MAJOR.MINOR.PATCH version', + ); + } + + $pins[] = new Pin( + $dependency->name, + $current, + $match[1][1], + $match[1][1] + strlen($current), + ); + } + + return $pins; + } + + /** + * @param list $pins + * @param list $selected + */ + public function replace( + string $content, + array $pins, + array $selected, + ): string { + if (count($pins) !== count($selected)) { + throw new Exception('Every dependency pin must have a selected value'); + } + + for ($index = count($pins) - 1; $index >= 0; --$index) { + $pin = $pins[$index]; + $content = substr($content, 0, $pin->start) + . $selected[$index] + . substr($content, $pin->end); + } + + return $content; + } + + /** + * @return list> + */ + private function matches(string $pattern, string $content): array + { + $count = preg_match_all( + $pattern, + $content, + $matches, + PREG_SET_ORDER | PREG_OFFSET_CAPTURE, + ); + if ($count === false) { + throw new Exception('Unable to inspect Dockerfile declarations'); + } + + return $matches; + } + + /** + * @return array + */ + private function single( + string $pattern, + string $content, + string $declaration, + ): array { + $matches = $this->matches($pattern, $content); + if (count($matches) !== 1) { + throw new Exception( + "Expected exactly one {$declaration} declaration, found " + . count($matches), + ); + } + + return $matches[0]; + } +} diff --git a/.github/scripts/src/Dependency/Exception.php b/.github/scripts/src/Dependency/Exception.php new file mode 100644 index 0000000..d062456 --- /dev/null +++ b/.github/scripts/src/Dependency/Exception.php @@ -0,0 +1,11 @@ + [ + 'follow_location' => 1, + 'timeout' => $this->timeout, + ], + ]); + + error_clear_last(); + $content = @file_get_contents($url, false, $context); + if ($content === false) { + $error = error_get_last(); + $detail = trim($error['message'] ?? 'unknown error'); + + throw new Exception("Failed to fetch {$url}: {$detail}"); + } + + return $content; + } +} diff --git a/.github/scripts/src/Dependency/Pin.php b/.github/scripts/src/Dependency/Pin.php new file mode 100644 index 0000000..0548911 --- /dev/null +++ b/.github/scripts/src/Dependency/Pin.php @@ -0,0 +1,16 @@ + $changes + */ + public function __construct( + public string $content, + public array $changes, + ) { + } + + public function changed(): bool + { + foreach ($this->changes as $change) { + if ($change->changed()) { + return true; + } + } + + return false; + } +} diff --git a/.github/scripts/src/Dependency/Program.php b/.github/scripts/src/Dependency/Program.php new file mode 100644 index 0000000..f0f86ba --- /dev/null +++ b/.github/scripts/src/Dependency/Program.php @@ -0,0 +1,81 @@ + $arguments + */ + public function execute(array $arguments): Result + { + try { + set_error_handler( + static function ( + int $severity, + string $message, + string $file, + int $line, + ): bool { + if ((error_reporting() & $severity) === 0) { + return false; + } + + throw new ErrorException( + $message, + 0, + $severity, + $file, + $line, + ); + }, + ); + + try { + return new Result( + 0, + $this->console->execute($arguments) . PHP_EOL, + '', + ); + } catch (UsageException $exception) { + return new Result( + 2, + '', + 'Error: ' . $this->detail($exception) . PHP_EOL, + ); + } catch (Throwable $exception) { + return new Result( + 1, + '', + 'Error: ' . $this->detail($exception) . PHP_EOL, + ); + } + } finally { + restore_error_handler(); + } + } + + private function detail(Throwable $exception): string + { + $detail = preg_replace( + '/\s+/', + ' ', + trim($exception->getMessage()), + ); + + return $detail === null || $detail === '' + ? 'Dependency update failed' + : $detail; + } +} diff --git a/.github/scripts/src/Dependency/Reporter.php b/.github/scripts/src/Dependency/Reporter.php new file mode 100644 index 0000000..93f0849 --- /dev/null +++ b/.github/scripts/src/Dependency/Reporter.php @@ -0,0 +1,38 @@ +changes as $change) { + if ($change->changed()) { + ++$changed; + } + + $result = $change->changed() ? 'Updated' : 'Current'; + $rows[] = "| {$change->name} | `{$change->current}` " + . "| `{$change->latest}` | {$result} |"; + } + + $rows[] = ''; + $rows[] = "**Updates:** {$changed}"; + $rows[] = ''; + $rows[] = $changed > 0 + ? 'Dockerfile pins were updated.' + : 'No dependency updates were found.'; + + return implode("\n", $rows); + } +} diff --git a/.github/scripts/src/Dependency/Resolver.php b/.github/scripts/src/Dependency/Resolver.php new file mode 100644 index 0000000..2e98488 --- /dev/null +++ b/.github/scripts/src/Dependency/Resolver.php @@ -0,0 +1,187 @@ +runner->run($command)->output; + } catch (CommandException $exception) { + throw new Exception($exception->getMessage(), previous: $exception); + } + + $count = preg_match_all( + '/^Digest:[ \t]*(' . self::DIGEST_PATTERN . ')[ \t]*$/m', + $output, + $matches, + ); + if ( + $count !== 1 + || preg_match( + '/\A' . self::DIGEST_PATTERN . '\z/', + $matches[1][0], + ) !== 1 + ) { + throw new Exception( + 'Expected one lowercase sha256 digest for ' . Catalog::BASE, + ); + } + + return $matches[1][0]; + } + + /** + * @return list + */ + public function releases(Dependency $dependency): array + { + if ($dependency->source instanceof Git) { + $command = [ + 'git', + 'ls-remote', + '--tags', + '--refs', + $dependency->source->url(), + ]; + + try { + $output = $this->runner->run($command)->output; + } catch (CommandException $exception) { + throw new Exception($exception->getMessage(), previous: $exception); + } + + $releases = $this->git($output); + } elseif ($dependency->source instanceof PECL) { + $releases = $this->pecl( + $this->fetcher->fetch($dependency->source->url()), + ); + } else { + throw new Exception( + "Unsupported source for {$dependency->name}", + ); + } + + if ($releases === []) { + throw new Exception( + "No exact stable releases found for {$dependency->name}", + ); + } + + return $releases; + } + + /** + * @return list + */ + public function git(string $output): array + { + $releases = []; + $lines = preg_split('/\R/', $output); + if ($lines === false) { + throw new Exception('Unable to inspect git release tags'); + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + $fields = preg_split('/\s+/', $line); + if ( + $fields === false + || count($fields) !== 2 + || ! str_starts_with($fields[1], 'refs/tags/') + ) { + continue; + } + + $tag = substr($fields[1], strlen('refs/tags/')); + if (Version::parse($tag) !== null) { + $releases[] = $tag; + } + } + + return $releases; + } + + /** + * @return list + */ + public function pecl(string $document): array + { + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $xml = new DOMDocument(); + if (! $xml->loadXML($document, LIBXML_NONET)) { + $errors = libxml_get_errors(); + $detail = trim($errors[0]->message ?? 'unknown error'); + + throw new Exception( + "Invalid PECL release XML: {$detail}", + ); + } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + $releases = []; + $root = $xml->documentElement; + if ($root === null) { + return $releases; + } + + foreach ($root->childNodes as $release) { + if (! $release instanceof DOMElement || $release->localName !== 'r') { + continue; + } + + $fields = []; + foreach ($release->childNodes as $field) { + if ($field instanceof DOMElement) { + $fields[$field->localName] = trim($field->textContent); + } + } + + $spelling = $fields['v'] ?? ''; + if ( + strtolower($fields['s'] ?? '') === 'stable' + && Version::parse($spelling) !== null + ) { + $releases[] = $spelling; + } + } + + return $releases; + } +} diff --git a/.github/scripts/src/Dependency/Selector.php b/.github/scripts/src/Dependency/Selector.php new file mode 100644 index 0000000..a2751bf --- /dev/null +++ b/.github/scripts/src/Dependency/Selector.php @@ -0,0 +1,54 @@ + $releases + */ + public function select(string $current, iterable $releases): string + { + $currentVersion = Version::parse($current); + if ($currentVersion === null) { + throw new Exception("Invalid current version: {$current}"); + } + + $candidates = []; + $latest = null; + foreach ($releases as $release) { + $version = Version::parse($release); + if ( + $version === null + || $version->major !== $currentVersion->major + || $version->compare($currentVersion) <= 0 + ) { + continue; + } + + $comparison = $latest === null ? 1 : $version->compare($latest); + if ($comparison > 0) { + $latest = $version; + $candidates = [$release]; + } elseif ($comparison === 0) { + $candidates[] = $release; + } + } + + if ($latest === null) { + return $current; + } + + $prefixed = str_starts_with($current, 'v'); + $matching = array_values(array_filter( + $candidates, + static fn (string $release): bool => str_starts_with($release, 'v') === $prefixed, + )); + $selected = $matching === [] ? $candidates : $matching; + sort($selected, SORT_STRING); + + return $selected[0]; + } +} diff --git a/.github/scripts/src/Dependency/Source.php b/.github/scripts/src/Dependency/Source.php new file mode 100644 index 0000000..fa346f0 --- /dev/null +++ b/.github/scripts/src/Dependency/Source.php @@ -0,0 +1,10 @@ +url; + } +} diff --git a/.github/scripts/src/Dependency/Source/PECL.php b/.github/scripts/src/Dependency/Source/PECL.php new file mode 100644 index 0000000..8ade1f2 --- /dev/null +++ b/.github/scripts/src/Dependency/Source/PECL.php @@ -0,0 +1,22 @@ +url; + } +} diff --git a/.github/scripts/src/Dependency/Updater.php b/.github/scripts/src/Dependency/Updater.php new file mode 100644 index 0000000..9195f18 --- /dev/null +++ b/.github/scripts/src/Dependency/Updater.php @@ -0,0 +1,37 @@ +application->plan($content); + if (! $plan->changed() || $dryRun) { + return $plan; + } + + $written = file_put_contents($path, $plan->content, LOCK_EX); + if ($written !== strlen($plan->content)) { + throw new Exception("Unable to write {$path}"); + } + + return $plan; + } +} diff --git a/.github/scripts/src/Dependency/UsageException.php b/.github/scripts/src/Dependency/UsageException.php new file mode 100644 index 0000000..580d040 --- /dev/null +++ b/.github/scripts/src/Dependency/UsageException.php @@ -0,0 +1,11 @@ +major, $other->major], + [$this->minor, $other->minor], + [$this->patch, $other->patch], + ] as [$current, $candidate]) { + $comparison = strlen($current) <=> strlen($candidate); + if ($comparison !== 0) { + return $comparison; + } + + $comparison = $current <=> $candidate; + if ($comparison !== 0) { + return $comparison; + } + } + + return 0; + } +} diff --git a/.github/scripts/src/Parity/Exception.php b/.github/scripts/src/Parity/Exception.php new file mode 100644 index 0000000..d903e1d --- /dev/null +++ b/.github/scripts/src/Parity/Exception.php @@ -0,0 +1,11 @@ +contracts(); + $executed = $this->executed(); + + foreach ($contracts as $contract) { + $states = $executed[$contract] ?? []; + if ($states === []) { + throw new Exception( + "Mapped parity test {$contract} was not executed", + ); + } + foreach (['error', 'failed', 'skipped'] as $state) { + if (in_array($state, $states, true)) { + throw new Exception( + "Mapped parity test {$contract} {$state}", + ); + } + } + } + + return count($contracts); + } + + /** + * @return list + */ + private function contracts(): array + { + $content = file_get_contents($this->manifest); + if ($content === false) { + throw new Exception( + "Unable to read parity manifest {$this->manifest}", + ); + } + + try { + $manifest = json_decode( + $content, + true, + 512, + JSON_THROW_ON_ERROR, + ); + } catch (JsonException $exception) { + throw new Exception( + 'Invalid parity manifest: ' . $exception->getMessage(), + previous: $exception, + ); + } + if (! is_array($manifest)) { + throw new Exception('Parity manifest must be an object'); + } + $baseline = $manifest['baseline'] ?? null; + $items = $manifest['contracts'] ?? null; + if ( + ! is_array($baseline) + || ! is_int($baseline['total'] ?? null) + || ! is_array($items) + ) { + throw new Exception('Parity manifest structure is invalid'); + } + + $contracts = []; + foreach ($items as $item) { + $contract = is_array($item) ? ($item['php'] ?? null) : null; + if ( + ! is_string($contract) + || preg_match( + '/\A[A-Za-z_][A-Za-z0-9_\\\\]*::' + . '[A-Za-z_][A-Za-z0-9_]*\z/D', + $contract, + ) !== 1 + ) { + throw new Exception( + 'Parity manifest contains an invalid PHP contract', + ); + } + $contracts[] = $contract; + } + if ($baseline['total'] !== count($contracts)) { + throw new Exception( + 'Parity manifest total does not match its contracts', + ); + } + if (count(array_unique($contracts)) !== count($contracts)) { + throw new Exception( + 'Parity manifest contains duplicate PHP contracts', + ); + } + + return $contracts; + } + + /** + * @return array> + */ + private function executed(): array + { + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $loaded = $document->load($this->results, LIBXML_NONET); + if (! $loaded) { + $errors = libxml_get_errors(); + $detail = trim( + $errors[0]->message ?? 'unknown XML error', + ); + + throw new Exception( + "Invalid PHPUnit results: {$detail}", + ); + } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + $nodes = (new DOMXPath($document))->query('//testcase'); + if ($nodes === false) { + throw new Exception('Unable to query PHPUnit results'); + } + + $executed = []; + foreach ($nodes as $node) { + if (! $node instanceof DOMElement) { + continue; + } + $class = $node->getAttribute('class'); + $name = $node->getAttribute('name'); + $dataset = strpos($name, ' with data set '); + $method = $dataset === false + ? $name + : substr($name, 0, $dataset); + if ($class === '' || $method === '') { + continue; + } + + $state = match (true) { + $node->getElementsByTagName('error')->length > 0 => 'error', + $node->getElementsByTagName('failure')->length > 0 => 'failed', + $node->getElementsByTagName('skipped')->length > 0 => 'skipped', + default => 'passed', + }; + $executed["{$class}::{$method}"][] = $state; + } + + return $executed; + } +} diff --git a/.github/scripts/tests/E2E/Bin/DependenciesTest.php b/.github/scripts/tests/E2E/Bin/DependenciesTest.php new file mode 100644 index 0000000..7363111 --- /dev/null +++ b/.github/scripts/tests/E2E/Bin/DependenciesTest.php @@ -0,0 +1,126 @@ +execute('--help'); + + self::assertSame(0, $result->code); + self::assertSame(Console::USAGE . PHP_EOL, $result->output); + self::assertSame('', $result->error); + } + + public function test_invalid_arguments_use_standard_error_and_exit_two(): void + { + $result = $this->execute('--unknown'); + + self::assertSame(2, $result->code); + self::assertSame('', $result->output); + self::assertSame( + "Error: Unknown dependency updater argument '--unknown'" + . PHP_EOL, + $result->error, + ); + } + + public function test_domain_failures_use_standard_error_and_exit_one(): void + { + $result = $this->execute( + '--dockerfile', + '/tmp/not-a-dockerfile', + ); + + self::assertSame(1, $result->code); + self::assertSame('', $result->output); + self::assertSame( + 'Error: The update target must be named Dockerfile' . PHP_EOL, + $result->error, + ); + self::assertStringNotContainsString('Stack trace', $result->error); + } + + public function test_runtime_warnings_are_returned_as_one_concise_error(): void + { + $path = sys_get_temp_dir() + . '/docker-base-missing-' + . bin2hex(random_bytes(8)) + . '/Dockerfile'; + $result = $this->execute('--dockerfile', $path); + + self::assertSame(1, $result->code); + self::assertSame('', $result->output); + self::assertStringStartsWith( + "Error: file_get_contents({$path}):", + $result->error, + ); + self::assertSame(1, substr_count($result->error, PHP_EOL)); + self::assertStringNotContainsString('Warning:', $result->error); + self::assertStringNotContainsString('Stack trace', $result->error); + } + + public function test_success_report_uses_standard_output_and_exit_zero(): void + { + $directory = sys_get_temp_dir() + . '/docker-base-entrypoint-' + . bin2hex(random_bytes(8)); + if (! mkdir($directory)) { + self::fail( + "Unable to create temporary directory: {$directory}", + ); + } + $path = "{$directory}/Dockerfile"; + self::assertSame( + strlen(Fixture::dockerfile()), + file_put_contents($path, Fixture::dockerfile()), + ); + + try { + $result = (new Process($this->root()))->run( + [ + PHP_BINARY, + '.github/scripts/tests/E2E/Bin/Fixture/Dependencies.php', + $path, + '--dry-run', + ], + check: false, + ); + + self::assertSame(0, $result->code); + self::assertStringContainsString( + '**Updates:** 0', + $result->output, + ); + self::assertSame('', $result->error); + } finally { + unlink($path); + rmdir($directory); + } + } + + private function execute(string ...$arguments): Result + { + $command = [ + PHP_BINARY, + '.github/scripts/bin/dependencies.php', + ]; + array_push($command, ...$arguments); + + return (new Process($this->root()))->run($command, check: false); + } + + private function root(): string + { + return dirname(__DIR__, 5); + } +} diff --git a/.github/scripts/tests/E2E/Bin/Fixture/Dependencies.php b/.github/scripts/tests/E2E/Bin/Fixture/Dependencies.php new file mode 100644 index 0000000..7f41804 --- /dev/null +++ b/.github/scripts/tests/E2E/Bin/Fixture/Dependencies.php @@ -0,0 +1,54 @@ +execute(array_slice($arguments, 2)); + +if ($result->output !== '') { + fwrite(STDOUT, $result->output); +} +if ($result->error !== '') { + fwrite(STDERR, $result->error); +} + +exit($result->code); diff --git a/.github/scripts/tests/E2E/Bin/OrchestratorTest.php b/.github/scripts/tests/E2E/Bin/OrchestratorTest.php new file mode 100644 index 0000000..8a9e67f --- /dev/null +++ b/.github/scripts/tests/E2E/Bin/OrchestratorTest.php @@ -0,0 +1,146 @@ + $arguments + */ + #[DataProvider('operations')] + public function test_non_validate_operations_do_not_read_standard_input( + array $arguments, + ): void { + [$process, $pipes] = $this->start($arguments); + + try { + self::assertTrue( + $this->exits($process), + implode(' ', $arguments) . ' waited for standard input', + ); + } finally { + $this->close($process, $pipes); + } + } + + public function test_validate_pull_reads_standard_input(): void + { + [$process, $pipes] = $this->start([ + 'validate-pull', + 'automation/dependencies-1-1', + str_repeat('a', 40), + str_repeat('b', 40), + ]); + + try { + usleep(100_000); + self::assertTrue( + proc_get_status($process)['running'], + 'validate-pull exited before standard input was available', + ); + self::assertSame(2, fwrite($pipes[0], '{}')); + fclose($pipes[0]); + unset($pipes[0]); + self::assertTrue( + $this->exits($process), + 'validate-pull did not consume standard input', + ); + } finally { + $this->close($process, $pipes); + } + } + + /** + * @return iterable}> + */ + public static function operations(): iterable + { + yield 'recover' => [['recover', 'unexpected']]; + yield 'merge' => [['merge']]; + yield 'prepare' => [['prepare']]; + yield 'wait' => [['wait']]; + yield 'publish' => [['publish']]; + yield 'wait-checks' => [['wait-checks']]; + } + + /** + * @param list $arguments + * + * @return array{ + * resource, + * array + * } + */ + private function start(array $arguments): array + { + $pipes = []; + $process = proc_open( + [ + PHP_BINARY, + '.github/scripts/bin/orchestrator.php', + ...$arguments, + ], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $this->root(), + [ + 'GITHUB_API_VERSION' => '2026-03-10', + 'GITHUB_OUTPUT' => sys_get_temp_dir() + . '/docker-base-orchestrator-output', + 'GITHUB_REPOSITORY' => 'appwrite/docker-base', + ], + ['bypass_shell' => true], + ); + if (! is_resource($process)) { + self::fail('Unable to start orchestrator process'); + } + + return [$process, $pipes]; + } + + /** + * @param resource $process + */ + private function exits(mixed $process): bool + { + $deadline = microtime(true) + 1; + do { + if (! proc_get_status($process)['running']) { + return true; + } + usleep(10_000); + } while (microtime(true) < $deadline); + + return false; + } + + /** + * @param resource $process + * @param array $pipes + */ + private function close(mixed $process, array $pipes): void + { + $status = proc_get_status($process); + if ($status['running']) { + proc_terminate($process); + } + foreach ($pipes as $pipe) { + fclose($pipe); + } + proc_close($process); + } + + private function root(): string + { + return dirname(__DIR__, 5); + } +} diff --git a/.github/scripts/tests/E2E/Bin/ParityTest.php b/.github/scripts/tests/E2E/Bin/ParityTest.php new file mode 100644 index 0000000..1a24569 --- /dev/null +++ b/.github/scripts/tests/E2E/Bin/ParityTest.php @@ -0,0 +1,128 @@ +evidence(); + + try { + $result = $this->execute($results, $manifest); + + self::assertSame(0, $result->code); + self::assertSame( + 'Verified 1 parity contracts.' . PHP_EOL, + $result->output, + ); + self::assertSame('', $result->error); + } finally { + $this->remove($directory); + } + } + + public function test_rejects_unexecuted_contracts_at_the_process_boundary(): void + { + [$directory, $manifest, $results] = $this->evidence(false); + + try { + $result = $this->execute($results, $manifest); + + self::assertSame(1, $result->code); + self::assertSame('', $result->output); + self::assertSame( + 'Error: Mapped parity test ' + . 'Example\\ContractTest::test_contract ' + . 'was not executed' + . PHP_EOL, + $result->error, + ); + } finally { + $this->remove($directory); + } + } + + public function test_rejects_invalid_arguments_at_the_process_boundary(): void + { + $result = $this->execute(); + + self::assertSame(2, $result->code); + self::assertSame('', $result->output); + self::assertSame( + 'Usage: parity.php RESULTS [MANIFEST]' . PHP_EOL, + $result->error, + ); + } + + private function execute(string ...$arguments): Result + { + $command = [ + PHP_BINARY, + '.github/scripts/bin/parity.php', + ]; + array_push($command, ...$arguments); + + return (new Process($this->root()))->run($command, check: false); + } + + /** + * @return array{string, string, string} + */ + private function evidence(bool $executed = true): array + { + $directory = sys_get_temp_dir() + . '/docker-base-process-evidence-' + . bin2hex(random_bytes(8)); + if (! mkdir($directory)) { + self::fail( + "Unable to create temporary directory: {$directory}", + ); + } + $manifest = "{$directory}/manifest.json"; + $results = "{$directory}/results.xml"; + $manifestContent = json_encode( + [ + 'baseline' => ['total' => 1], + 'contracts' => [[ + 'php' => 'Example\\ContractTest::test_contract', + ]], + ], + JSON_THROW_ON_ERROR, + ); + self::assertSame( + strlen($manifestContent), + file_put_contents($manifest, $manifestContent), + ); + $case = $executed + ? '' + : ''; + $resultsContent = '' + . "{$case}"; + self::assertSame( + strlen($resultsContent), + file_put_contents($results, $resultsContent), + ); + + return [$directory, $manifest, $results]; + } + + private function remove(string $directory): void + { + unlink("{$directory}/manifest.json"); + unlink("{$directory}/results.xml"); + rmdir($directory); + } + + private function root(): string + { + return dirname(__DIR__, 5); + } +} diff --git a/.github/scripts/tests/E2E/Command/ProcessTest.php b/.github/scripts/tests/E2E/Command/ProcessTest.php new file mode 100644 index 0000000..96fa40d --- /dev/null +++ b/.github/scripts/tests/E2E/Command/ProcessTest.php @@ -0,0 +1,131 @@ +run([ + PHP_BINARY, + '-r', + 'fwrite(STDOUT, \'ready\');', + ]); + + self::assertSame(0, $result->code); + self::assertSame('ready', $result->output); + self::assertSame('', $result->error); + } + + public function testReturnsAnUncheckedFailure(): void + { + $result = (new Process())->run( + [ + PHP_BINARY, + '-r', + 'fwrite(STDERR, \'failed\'); exit(7);', + ], + check: false, + ); + + self::assertSame(7, $result->code); + self::assertSame('', $result->output); + self::assertSame('failed', $result->error); + } + + public function testThrowsACheckedFailureWithItsResult(): void + { + try { + (new Process())->run([ + PHP_BINARY, + '-r', + 'fwrite(STDERR, \'failed\'); exit(9);', + ]); + self::fail('A checked command failure must throw'); + } catch (Exception $exception) { + $result = $exception->result; + if ($result === null) { + self::fail('A checked command failure must contain its result'); + } + + self::assertSame(9, $result->code); + self::assertSame('failed', $result->error); + } + } + + public function testExplicitEnvironmentReplacesInheritedEnvironment(): void + { + putenv('DOCKER_BASE_INHERITED_SENTINEL=should-not-leak'); + + try { + $result = (new Process(environment: ['DOCKER_BASE_MARKER' => 'present']))->run([ + PHP_BINARY, + '-r', + 'echo getenv("DOCKER_BASE_MARKER") . "|" . (getenv("DOCKER_BASE_INHERITED_SENTINEL") ?: "absent");', + ]); + } finally { + putenv('DOCKER_BASE_INHERITED_SENTINEL'); + } + + self::assertSame('present|absent', $result->output); + } + + public function testHonorsAndRestoresWorkingDirectoryWithoutExplicitEnvironment(): void + { + $directory = sys_get_temp_dir(); + $workingDirectory = getcwd(); + if ($workingDirectory === false) { + self::fail('Unable to determine the current working directory'); + } + + $result = (new Process($directory))->run([ + PHP_BINARY, + '-r', + 'echo getcwd();', + ]); + + self::assertSame(realpath($directory), $result->output); + self::assertSame($workingDirectory, getcwd()); + } + + public function testRestoresWorkingDirectoryWhenCommandFails(): void + { + $directory = sys_get_temp_dir(); + $workingDirectory = getcwd(); + if ($workingDirectory === false) { + self::fail('Unable to determine the current working directory'); + } + + try { + (new Process($directory))->run([ + PHP_BINARY, + '-r', + 'exit(11);', + ]); + self::fail('A checked command failure must throw'); + } catch (Exception $exception) { + self::assertSame(11, $exception->result?->code); + } + + self::assertSame($workingDirectory, getcwd()); + } + + public function testRejectsAnInvalidWorkingDirectory(): void + { + $directory = sys_get_temp_dir() . '/docker-base-missing-directory'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Unable to start command in directory: {$directory}"); + + (new Process($directory))->run([PHP_BINARY, '-r', 'echo "ready";']); + } +} diff --git a/.github/scripts/tests/Support/Queue.php b/.github/scripts/tests/Support/Queue.php new file mode 100644 index 0000000..ecfcd31 --- /dev/null +++ b/.github/scripts/tests/Support/Queue.php @@ -0,0 +1,66 @@ + + */ + private array $results; + + /** + * @var list, check: bool}> + */ + private array $commands = []; + + /** + * @param list $results + */ + public function __construct(array $results) + { + $this->results = $results; + } + + /** + * @param list $command + */ + #[Override] + public function run(array $command, bool $check = true): Result + { + $this->commands[] = [ + 'command' => $command, + 'check' => $check, + ]; + $result = array_shift($this->results); + if ($result === null) { + throw new RuntimeException('No queued command result remains'); + } + if ($check && ! $result->succeeded()) { + throw new Exception($command, $result); + } + + return $result; + } + + /** + * @return list, check: bool}> + */ + public function commands(): array + { + return $this->commands; + } + + public function remaining(): int + { + return count($this->results); + } +} diff --git a/.github/scripts/tests/Unit/ArchitectureTest.php b/.github/scripts/tests/Unit/ArchitectureTest.php new file mode 100644 index 0000000..82b0b26 --- /dev/null +++ b/.github/scripts/tests/Unit/ArchitectureTest.php @@ -0,0 +1,222 @@ +getMessage()); + } + self::assertIsArray($equivalence); + self::assertSame( + [ + 'automation' => 43, + 'dependency' => 35, + 'adapter' => 13, + 'total' => 91, + ], + $equivalence['baseline'] ?? null, + ); + self::assertSame( + [ + 'bytes' => 5477, + 'sha256' => '50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77', + ], + $equivalence['deterministic'] ?? null, + ); + + $contracts = $equivalence['contracts'] ?? null; + self::assertIsArray($contracts); + self::assertCount(91, $contracts); + + $python = []; + $php = []; + $groups = []; + foreach ($contracts as $contract) { + self::assertIsArray($contract); + $pythonContract = $this->value($contract, 'python'); + $phpContract = $this->value($contract, 'php'); + $group = $this->value($contract, 'group'); + $this->value($contract, 'fixture'); + $this->value($contract, 'assertion'); + self::assertArrayNotHasKey('status', $contract); + + $python[] = $pythonContract; + $php[] = $phpContract; + $groups[] = $group; + + $method = explode('::', $phpContract); + self::assertCount(2, $method); + self::assertTrue( + class_exists($method[0]), + "Missing parity class {$method[0]}", + ); + self::assertTrue( + method_exists($method[0], $method[1]), + "Missing parity method {$phpContract}", + ); + } + + self::assertCount(91, array_unique($python)); + self::assertCount(91, array_unique($php)); + $counts = array_count_values($groups); + ksort($counts, SORT_STRING); + self::assertSame( + [ + 'adapter' => 13, + 'automation' => 43, + 'dependency' => 35, + ], + $counts, + ); + } + + public function test_contains_no_python_implementation_or_invocation(): void + { + $root = dirname(__DIR__, 4); + $python = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( + $root, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + ); + foreach ($iterator as $file) { + if (! $file instanceof SplFileInfo || ! $file->isFile()) { + continue; + } + $path = $file->getPathname(); + $relative = substr($path, strlen($root) + 1); + if ( + str_starts_with($relative, 'vendor/') + || str_starts_with($relative, '.git/') + ) { + continue; + } + if ( + $file->getExtension() === 'py' + || str_contains($relative, '/__pycache__/') + || str_ends_with($relative, '.pyc') + ) { + $python[] = $relative; + } + } + self::assertSame([], $python, 'Python artifacts remain'); + + $workflow = file_get_contents( + $root . '/.github/workflows/dependencies.yml', + ); + self::assertIsString($workflow); + self::assertSame( + 0, + preg_match('/\bpython(?:3)?\b|<<\s*[\'"]?PY\b/i', $workflow), + ); + } + + public function test_declares_and_preflights_required_php_extensions(): void + { + $root = dirname(__DIR__, 4); + $document = file_get_contents($root . '/composer.json'); + self::assertIsString($document); + + try { + $composer = json_decode( + $document, + true, + 512, + JSON_THROW_ON_ERROR, + ); + } catch (JsonException $exception) { + self::fail($exception->getMessage()); + } + self::assertIsArray($composer); + $requirements = $composer['require'] ?? null; + self::assertIsArray($requirements); + + $extensions = []; + foreach ($requirements as $package => $constraint) { + if (! is_string($package) || ! str_starts_with($package, 'ext-')) { + continue; + } + self::assertSame('*', $constraint); + $extensions[] = substr($package, strlen('ext-')); + } + sort($extensions, SORT_STRING); + self::assertSame( + [ + 'dom', + 'filter', + 'json', + 'libxml', + 'mbstring', + 'openssl', + 'phar', + 'tokenizer', + 'xml', + 'xmlwriter', + ], + $extensions, + ); + + $workflow = file_get_contents( + $root . '/.github/workflows/dependencies.yml', + ); + self::assertIsString($workflow); + self::assertSame( + 1, + preg_match( + '/\\$required = \\[(.*?)\\];/s', + $workflow, + $matched, + ), + ); + $preflightBlock = $matched[1] ?? null; + self::assertIsString($preflightBlock); + self::assertSame( + 10, + preg_match_all( + '/"([a-z][a-z0-9_]*)"/', + $preflightBlock, + $preflight, + ), + ); + self::assertSame($extensions, $preflight[1]); + } + + /** + * @param array $contract + */ + private function value(array $contract, string $field): string + { + self::assertArrayHasKey($field, $contract); + $value = $contract[$field]; + if (! is_string($value)) { + self::fail("Parity field {$field} must be a string"); + } + self::assertNotSame('', trim($value)); + + return $value; + } +} diff --git a/.github/scripts/tests/Unit/Automation/DeadlineTest.php b/.github/scripts/tests/Unit/Automation/DeadlineTest.php new file mode 100644 index 0000000..5e61918 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/DeadlineTest.php @@ -0,0 +1,53 @@ +start(); + $deadline = Deadline::after($start, 600); + + self::assertSame( + false, + $deadline->expired($start->modify('+9 minutes')), + ); + self::assertSame( + true, + $deadline->expired($start->modify('+10 minutes')), + ); + self::assertSame( + 60, + $deadline->remaining($start->modify('+9 minutes')), + ); + self::assertSame( + 0, + $deadline->remaining($start->modify('+11 minutes')), + ); + self::assertSame(7_200, Deadline::WORKFLOW_TIMEOUT_SECONDS); + } + + #[Test] + public function test_rejects_naive_times_and_nonpositive_timeouts(): void + { + self::assertSame('+00:00', $this->start()->format('P')); + + $this->expectException(InvalidArgumentException::class); + Deadline::after($this->start(), 0); + } + + private function start(): DateTimeImmutable + { + return new DateTimeImmutable('2026-07-24T08:00:00+00:00'); + } +} diff --git a/.github/scripts/tests/Unit/Automation/MergeResultTest.php b/.github/scripts/tests/Unit/Automation/MergeResultTest.php new file mode 100644 index 0000000..4b2ed85 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/MergeResultTest.php @@ -0,0 +1,86 @@ +expectException(HeadChangedException::class); + + MergeValidator::validateResult( + new MergeResult( + head: str_repeat('c', 40), + state: 'merged', + commit: str_repeat('b', 40), + parents: [str_repeat('d', 40)], + ), + str_repeat('a', 40), + str_repeat('d', 40), + ); + } + + #[Test] + public function test_rejects_merge_commit_for_a_different_tested_base(): void + { + $this->expectException(HeadChangedException::class); + + MergeValidator::validateResult( + new MergeResult( + head: str_repeat('a', 40), + state: 'merged', + commit: str_repeat('b', 40), + parents: [str_repeat('e', 40)], + ), + str_repeat('a', 40), + str_repeat('d', 40), + ); + } + + #[Test] + public function test_rejects_a_non_squash_merge_commit(): void + { + $this->expectException(HeadChangedException::class); + + MergeValidator::validateResult( + new MergeResult( + head: str_repeat('a', 40), + state: 'merged', + commit: str_repeat('b', 40), + parents: [ + str_repeat('c', 40), + str_repeat('d', 40), + ], + ), + str_repeat('a', 40), + str_repeat('c', 40), + ); + } +} diff --git a/.github/scripts/tests/Unit/Automation/OrchestratorTest.php b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php new file mode 100644 index 0000000..d338c8a --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php @@ -0,0 +1,772 @@ +commandResult(1, '{"status":"404"}'), + $this->commandResult( + output: '{"data":{"repository":{"release":null}}}', + ), + ]); + + self::assertNull($this->github($runner)->releaseByTag('1.4.5')); + self::assertSame(0, $runner->remaining()); + self::assertSame( + 'repos/appwrite/docker-base/releases/tags/1.4.5', + $runner->commands()[0]['command'][4], + ); + self::assertSame('graphql', $runner->commands()[1]['command'][4]); + } + + public function test_finds_draft_after_release_by_tag_returns_404(): void + { + $target = str_repeat('a', 40); + $runner = new Queue([ + $this->commandResult(1, '{"status":"404"}'), + $this->commandResult( + output: json_encode( + [ + 'data' => [ + 'repository' => [ + 'release' => [ + 'databaseId' => 10, + 'tagName' => '1.4.5', + 'isDraft' => true, + 'isPrerelease' => false, + 'description' => '', + 'tagCommit' => ['oid' => $target], + ], + ], + ], + ], + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $draft = $this->github($runner)->releaseByTag('1.4.5'); + + self::assertNotNull($draft); + self::assertSame(10, $draft->identifier); + self::assertSame('1.4.5', $draft->tag); + self::assertSame($target, $draft->target); + self::assertTrue($draft->draft); + self::assertSame(0, $runner->remaining()); + } + + public function test_exact_lookup_finds_published_release_omitted_from_list(): void + { + $first = str_repeat('a', 40); + $second = str_repeat('b', 40); + $tags = [ + new Tag('1.4.3', $first), + new Tag('1.4.4', $second), + ]; + $runner = new Queue([ + $this->commandResult( + output: $this->pages([ + $this->release( + identifier: 9, + tag: '1.4.3', + target: $first, + draft: false, + ), + ]), + ), + $this->commandResult( + output: json_encode( + $this->release( + identifier: 9, + tag: '1.4.3', + target: $first, + draft: false, + ), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult( + output: json_encode( + $this->release( + identifier: 10, + tag: '1.4.4', + target: $second, + draft: false, + ), + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $releases = $this->github($runner)->releases($tags); + + self::assertSame( + ['1.4.3', '1.4.4'], + array_map( + static fn (Recovery $release): string => $release->tag, + $releases, + ), + ); + self::assertSame(0, $runner->remaining()); + self::assertStringEndsWith( + '/releases/tags/1.4.3', + $runner->commands()[1]['command'][4], + ); + self::assertStringEndsWith( + '/releases/tags/1.4.4', + $runner->commands()[2]['command'][4], + ); + self::assertSame( + [], + array_filter( + $runner->commands(), + static fn (array $call): bool => str_contains( + $call['command'][4] ?? '', + '/git/matching-refs/tags/', + ), + ), + ); + } + + public function test_release_discovery_orders_unbounded_snapshot_semantically(): void + { + $lower = '1.18446744073709551616.0'; + $higher = '1.18446744073709551617.0'; + $first = str_repeat('a', 40); + $second = str_repeat('b', 40); + $runner = new Queue([ + $this->commandResult( + output: $this->pages([ + $this->release( + identifier: 9, + tag: $lower, + target: $first, + draft: false, + ), + $this->release( + identifier: 10, + tag: $higher, + target: $second, + draft: false, + ), + ]), + ), + $this->commandResult( + output: json_encode( + $this->release( + identifier: 10, + tag: $higher, + target: $second, + draft: false, + ), + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $releases = $this->github($runner)->releases([ + new Tag($lower, $first), + new Tag($higher, $second), + ]); + + self::assertSame( + [$higher], + array_map( + static fn (Recovery $release): string => $release->tag, + $releases, + ), + ); + self::assertSame(0, $runner->remaining()); + self::assertStringEndsWith( + "/releases/tags/{$higher}", + $runner->commands()[1]['command'][4], + ); + } + + public function test_recovers_merge_cancelled_before_tag_on_next_no_diff_run(): void + { + $released = str_repeat('a', 40); + $target = str_repeat('b', 40); + $head = str_repeat('c', 40); + $base = str_repeat('d', 40); + $tags = [ + new Tag('1.4.4', $released), + ]; + $repository = $this->repository(); + $repository->expects(self::once()) + ->method('tags') + ->willReturn($tags); + $repository->expects(self::once()) + ->method('releases') + ->with(self::identicalTo($tags)) + ->willReturn([ + new Recovery( + 9, + '1.4.4', + $released, + 0, + false, + false, + '', + ), + ]); + $repository->expects(self::once()) + ->method('mergedPullRequests') + ->willReturn([ + new Merge( + number: 75, + target: $target, + head: $head, + parents: [$base], + base: 'main', + branch: 'automation/dependencies-100-1', + body: $this->pullBody($head, $base), + files: ['Dockerfile'], + state: 'merged', + ), + ]); + + $candidate = $this->orchestrator($repository)->recover(); + + self::assertNotNull($candidate); + self::assertNull($candidate->tag); + self::assertSame($target, $candidate->target); + self::assertSame(75, $candidate->pull); + self::assertNull($candidate->draft); + } + + public function test_resolves_recovery_commit_when_rest_merge_sha_is_null(): void + { + $head = str_repeat('a', 40); + $base = str_repeat('b', 40); + $commit = str_repeat('c', 40); + $body = $this->pullBody($head, $base); + $runner = new Queue([ + $this->commandResult( + output: $this->pages([ + [ + 'number' => 75, + 'merged_at' => '2026-07-24T00:00:00Z', + 'merge_commit_sha' => null, + 'head' => [ + 'ref' => 'automation/dependencies-100-1', + 'sha' => $head, + ], + 'base' => ['ref' => 'main'], + 'body' => $body, + ], + ]), + ), + $this->commandResult( + output: json_encode( + $this->pull( + $head, + $base, + 'MERGED', + $commit, + ), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult( + output: $this->pages([['filename' => 'Dockerfile']]), + ), + $this->commandResult( + output: json_encode( + ['parents' => [['sha' => $base]]], + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $merges = $this->github($runner)->mergedPullRequests(); + + self::assertCount(1, $merges); + self::assertSame(75, $merges[0]->number); + self::assertSame($commit, $merges[0]->target); + self::assertSame($head, $merges[0]->head); + self::assertSame([$base], $merges[0]->parents); + self::assertSame(['Dockerfile'], $merges[0]->files); + self::assertSame(0, $runner->remaining()); + } + + public function test_accepts_nonzero_merge_only_after_exact_remote_proof(): void + { + $head = str_repeat('a', 40); + $base = str_repeat('b', 40); + $commit = str_repeat('c', 40); + $runner = new Queue([ + $this->commandResult( + output: json_encode( + $this->pull($head, $base), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult(1), + $this->commandResult( + output: json_encode( + $this->pull($head, $base, 'MERGED', $commit), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult( + output: json_encode( + ['parents' => [['sha' => $base]]], + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $merged = $this->orchestrator( + $this->github($runner), + )->merge(75, $head, $base); + + self::assertSame($commit, $merged); + self::assertFalse($runner->commands()[1]['check']); + self::assertSame(0, $runner->remaining()); + } + + public function test_rejects_nonzero_merge_without_merged_remote_state(): void + { + $head = str_repeat('a', 40); + $base = str_repeat('b', 40); + $runner = new Queue([ + $this->commandResult( + output: json_encode( + $this->pull($head, $base), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult(1), + $this->commandResult( + output: json_encode( + $this->pull($head, $base), + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $this->expectException(PullRequestUnavailableException::class); + $this->orchestrator( + $this->github($runner), + )->merge(75, $head, $base); + } + + public function test_rejects_merge_when_squash_parent_is_not_tested_base(): void + { + $head = str_repeat('a', 40); + $base = str_repeat('b', 40); + $commit = str_repeat('c', 40); + $runner = new Queue([ + $this->commandResult( + output: json_encode( + $this->pull($head, $base), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult(), + $this->commandResult( + output: json_encode( + $this->pull($head, $base, 'MERGED', $commit), + JSON_THROW_ON_ERROR, + ), + ), + $this->commandResult( + output: json_encode( + ['parents' => [['sha' => str_repeat('d', 40)]]], + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $this->expectException(HeadChangedException::class); + $this->orchestrator( + $this->github($runner), + )->merge(75, $head, $base); + } + + public function test_existing_published_release_prevents_duplicate_draft(): void + { + $target = str_repeat('a', 40); + $runner = new Queue([ + $this->commandResult( + output: json_encode( + $this->release( + identifier: 10, + tag: '1.4.5', + target: $target, + draft: false, + ), + JSON_THROW_ON_ERROR, + ), + ), + ]); + + try { + $this->github($runner)->createDraft( + '1.4.5', + $target, + 75, + $this->draftBody($target, 75), + ); + self::fail('A published release must prevent draft creation'); + } catch (RuntimeException $exception) { + self::assertStringContainsString( + 'already published', + $exception->getMessage(), + ); + } + + self::assertCount(1, $runner->commands()); + self::assertSame(0, $runner->remaining()); + } + + public function test_existing_exact_draft_avoids_duplicate_create(): void + { + $target = str_repeat('a', 40); + $body = $this->draftBody($target, 75); + $runner = new Queue([ + $this->commandResult( + output: json_encode( + $this->release( + identifier: 10, + tag: '1.4.5', + target: $target, + draft: true, + body: $body, + ), + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $draft = $this->github($runner)->createDraft( + '1.4.5', + $target, + 75, + $body, + ); + + self::assertSame(10, $draft->identifier); + self::assertCount(1, $runner->commands()); + self::assertSame(0, $runner->remaining()); + } + + public function test_recovers_concurrently_created_draft_after_422(): void + { + $target = str_repeat('a', 40); + $body = $this->draftBody($target, 75); + $runner = new Queue([ + $this->commandResult(1, '{"status":"404"}'), + $this->commandResult( + output: '{"data":{"repository":{"release":null}}}', + ), + $this->commandResult(1, '{"status":"422"}'), + $this->commandResult( + output: json_encode( + $this->release( + identifier: 10, + tag: '1.4.5', + target: $target, + draft: true, + body: $body, + ), + JSON_THROW_ON_ERROR, + ), + ), + ]); + + $draft = $this->github($runner)->createDraft( + '1.4.5', + $target, + 75, + $body, + ); + + self::assertSame(10, $draft->identifier); + self::assertSame(0, $runner->remaining()); + } + + public function test_does_not_publish_when_prepublication_target_changed(): void + { + $repository = $this->repository(); + $repository->method('tag')->willReturn( + new Tag('1.4.5', 'wrong'), + ); + $repository->expects(self::never())->method('publish'); + + $this->expectException(TargetMismatchException::class); + $this->orchestrator($repository)->publish( + '1.4.5', + 'expected', + 75, + 10, + ); + } + + public function test_returns_release_to_draft_when_postpublication_target_changed(): void + { + $target = 'expected'; + $body = $this->draftBody($target, 75); + $preparation = new Preparation('1.4.5', $target, 75, 10); + $repository = $this->repository(); + $repository->method('tag')->willReturnOnConsecutiveCalls( + new Tag('1.4.5', $target), + new Tag('1.4.5', 'wrong'), + ); + $repository->method('draft')->willReturn( + new Recovery( + 10, + '1.4.5', + $target, + 75, + true, + false, + $body, + ), + ); + $repository->method('publish')->with( + self::equalTo($preparation), + )->willReturn( + new Recovery( + 10, + '1.4.5', + $target, + 75, + false, + false, + $body, + ), + ); + $repository->expects(self::once())->method('redraft')->with( + self::equalTo($preparation), + )->willReturn( + new Recovery( + 10, + '1.4.5', + $target, + 75, + true, + false, + $body, + ), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('returned to draft'); + $this->orchestrator($repository)->publish( + '1.4.5', + $target, + 75, + 10, + ); + } + + public function test_waits_for_the_four_exact_current_head_workflows(): void + { + $head = str_repeat('a', 40); + $branch = 'automation/dependencies-100-1'; + $created = new DateTimeImmutable('2026-07-24T08:00:00+00:00'); + $expected = [ + ['build-and-push.yml', 'Build and Push', 'push'], + ['dive.yml', 'Dive Test', 'push'], + [ + 'structure-test.yml', + 'Container Structure Test', + 'push', + ], + ['trivy.yml', 'Trivy Scan', 'pull_request'], + ]; + $actual = []; + $repository = $this->repository(); + $repository->expects(self::exactly(4)) + ->method('runs') + ->willReturnCallback( + static function ( + string $filename, + string $event, + string $runHead, + string $runBranch, + ) use ( + &$actual, + $expected, + $head, + $branch, + $created, + ): array { + $index = count($actual); + [$expectedFilename, $workflow, $expectedEvent] = + $expected[$index]; + $actual[] = [$filename, $workflow, $event]; + self::assertSame($expectedFilename, $filename); + self::assertSame($expectedEvent, $event); + self::assertSame($head, $runHead); + self::assertSame($branch, $runBranch); + + return [ + new Run( + $index + 1, + $workflow, + $event, + $head, + $branch, + $created, + 1, + 'completed', + 'success', + ), + ]; + }, + ); + $clock = $this->createStub(Clock::class); + $clock->method('now')->willReturn($created); + $sleeper = $this->createMock(Sleeper::class); + $sleeper->expects(self::never())->method('sleep'); + + (new Orchestrator($repository, $clock, $sleeper))->checks( + $branch, + $head, + '2026-07-24T08:00:00Z', + ); + + self::assertSame($expected, $actual); + } + + public function test_rejects_a_non_utc_workflow_boundary(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('absolute UTC timestamp'); + + $this->orchestrator($this->createStub(Repository::class))->checks( + 'automation/dependencies-100-1', + str_repeat('a', 40), + '2026-07-24T20:00:00+12:00', + ); + } + + private function github(Queue $runner): GitHub + { + return new GitHub( + $runner, + 'appwrite/docker-base', + '2026-03-10', + ); + } + + /** + * @return Repository&MockObject + */ + private function repository(): Repository + { + return $this->createMock(Repository::class); + } + + private function orchestrator(Repository $repository): Orchestrator + { + return new Orchestrator( + $repository, + $this->createStub(Clock::class), + $this->createStub(Sleeper::class), + ); + } + + private function commandResult( + int $code = 0, + string $output = '{}', + ): Result { + return new Result($code, $output, 'failure'); + } + + /** + * @return array + */ + private function release( + int $identifier, + string $tag, + string $target, + bool $draft, + bool $prerelease = false, + string $body = '', + ): array { + return [ + 'id' => $identifier, + 'tag_name' => $tag, + 'target_commitish' => $target, + 'draft' => $draft, + 'prerelease' => $prerelease, + 'body' => $body, + ]; + } + + /** + * @return array|int|string|null> + */ + private function pull( + string $head, + string $base, + string $state = 'OPEN', + ?string $commit = null, + ): array { + return [ + 'baseRefName' => 'main', + 'baseRefOid' => $base, + 'headRefOid' => $head, + 'mergeCommit' => $commit === null ? null : ['oid' => $commit], + 'mergeable' => 'MERGEABLE', + 'number' => 75, + 'reviewDecision' => 'APPROVED', + 'state' => $state, + ]; + } + + /** + * @param list> $items + */ + private function pages(array $items): string + { + return json_encode([$items], JSON_THROW_ON_ERROR); + } + + private function pullBody(string $head, string $base): string + { + return '' + . "\n" + . "\n"; + } + + private function draftBody(string $target, int $pull): string + { + return '' + . "\n" + . "\n" + . "\n\nAutomated weekly dependency release."; + } +} diff --git a/.github/scripts/tests/Unit/Automation/PayloadTest.php b/.github/scripts/tests/Unit/Automation/PayloadTest.php new file mode 100644 index 0000000..ac21e76 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/PayloadTest.php @@ -0,0 +1,105 @@ + '75', + 'base' => str_repeat('b', 40), + ], + Payload::validate( + $this->payload(), + 'automation/dependencies-100-1', + str_repeat('a', 40), + str_repeat('b', 40), + ), + ); + } + + /** + * @param array $change + */ + #[DataProvider('invalid')] + public function test_rejects_an_unproven_pull_request( + array $change, + string $message, + ): void { + $payload = json_decode( + $this->payload(), + true, + 512, + JSON_THROW_ON_ERROR, + ); + self::assertIsArray($payload); + foreach ($change as $name => $value) { + $payload[$name] = $value; + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + Payload::validate( + json_encode($payload, JSON_THROW_ON_ERROR), + 'automation/dependencies-100-1', + str_repeat('a', 40), + str_repeat('b', 40), + ); + } + + /** + * @return iterable, string}> + */ + public static function invalid(): iterable + { + yield 'number must be a positive integer' => [ + ['number' => '75'], + 'Pull request number is invalid', + ]; + yield 'base branch must be main' => [ + ['baseRefName' => 'release'], + 'Pull request does not target main', + ]; + yield 'base oid must be tested' => [ + ['baseRefOid' => str_repeat('c', 40)], + 'Pull request does not use the tested base', + ]; + yield 'head branch must be pushed' => [ + ['headRefName' => 'other'], + 'Pull request does not use the pushed head', + ]; + yield 'head oid must be pushed' => [ + ['headRefOid' => str_repeat('c', 40)], + 'Pull request does not use the pushed head', + ]; + yield 'state must be open' => [ + ['state' => 'CLOSED'], + 'Pull request is not open', + ]; + } + + private function payload(): string + { + return json_encode( + [ + 'baseRefName' => 'main', + 'baseRefOid' => str_repeat('b', 40), + 'createdAt' => '2026-07-24T08:00:00Z', + 'headRefName' => 'automation/dependencies-100-1', + 'headRefOid' => str_repeat('a', 40), + 'number' => 75, + 'state' => 'OPEN', + ], + JSON_THROW_ON_ERROR, + ); + } +} diff --git a/.github/scripts/tests/Unit/Automation/PullRequestTest.php b/.github/scripts/tests/Unit/Automation/PullRequestTest.php new file mode 100644 index 0000000..780fa17 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/PullRequestTest.php @@ -0,0 +1,96 @@ +expectNotToPerformAssertions(); + + PullRequestValidator::validate( + new PullRequest( + number: 75, + head: 'approved-head', + base: 'tested-base', + baseBranch: 'main', + state: 'open', + review: ReviewDecision::Approved, + mergeable: true, + ), + 'approved-head', + 'tested-base', + ); + } + + #[Test] + public function test_rejects_changed_head_even_if_currently_approved(): void + { + $this->expectException(HeadChangedException::class); + + PullRequestValidator::validate( + new PullRequest( + number: 75, + head: 'changed-head', + base: 'tested-base', + baseBranch: 'main', + state: 'open', + review: ReviewDecision::Approved, + mergeable: true, + ), + 'approved-head', + 'tested-base', + ); + } + + #[Test] + public function test_rejects_changed_base_after_ci_succeeds(): void + { + $this->expectException(HeadChangedException::class); + + PullRequestValidator::validate( + new PullRequest( + number: 75, + head: 'approved-head', + base: 'changed-base', + baseBranch: 'main', + state: 'open', + review: ReviewDecision::Approved, + mergeable: true, + ), + 'approved-head', + 'tested-base', + ); + } + + #[Test] + public function test_rejects_missing_current_approval(): void + { + $this->expectException(ApprovalMissingException::class); + + PullRequestValidator::validate( + new PullRequest( + number: 75, + head: 'approved-head', + base: 'tested-base', + baseBranch: 'main', + state: 'open', + review: ReviewDecision::ReviewRequired, + mergeable: true, + ), + 'approved-head', + 'tested-base', + ); + } +} diff --git a/.github/scripts/tests/Unit/Automation/RecoveryTest.php b/.github/scripts/tests/Unit/Automation/RecoveryTest.php new file mode 100644 index 0000000..233eaf1 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/RecoveryTest.php @@ -0,0 +1,284 @@ +assertCandidate( + RecoverySelector::select( + [new Tag(name: '1.4.5', target: $target)], + [ + $this->release(), + $this->release( + identifier: 9, + tag: '1.4.4', + draft: false, + ), + ], + [$this->merge()], + ), + tag: '1.4.5', + target: $target, + pull: 75, + draft: 10, + ); + } + + #[Test] + public function test_resumes_tag_when_draft_creation_failed_and_next_run_has_no_diff(): void + { + $target = str_repeat('a', 40); + + $this->assertCandidate( + RecoverySelector::select( + [new Tag(name: '1.4.5', target: $target)], + [ + $this->release( + identifier: 9, + tag: '1.4.4', + draft: false, + ), + ], + [$this->merge()], + ), + tag: '1.4.5', + target: $target, + pull: 75, + draft: null, + ); + } + + #[Test] + public function test_resumes_proven_merge_when_cancelled_before_tag_creation(): void + { + $target = str_repeat('d', 40); + + $this->assertCandidate( + RecoverySelector::select( + [ + new Tag( + name: '1.4.4', + target: str_repeat('a', 40), + ), + ], + [$this->release(tag: '1.4.4', draft: false)], + [$this->merge(number: 76, target: $target)], + ), + tag: null, + target: $target, + pull: 76, + draft: null, + ); + } + + #[Test] + public function test_does_not_resume_merge_of_an_untested_base(): void + { + self::assertSame( + null, + RecoverySelector::select( + [ + new Tag( + name: '1.4.4', + target: str_repeat('a', 40), + ), + ], + [$this->release(tag: '1.4.4', draft: false)], + [ + $this->merge( + target: str_repeat('d', 40), + parent: str_repeat('e', 40), + testedBase: str_repeat('c', 40), + ), + ], + ), + ); + } + + #[Test] + public function test_fails_closed_for_ambiguous_proven_untagged_merges(): void + { + $this->expectException(RecoveryException::class); + + RecoverySelector::select( + [ + new Tag( + name: '1.4.4', + target: str_repeat('a', 40), + ), + ], + [$this->release(tag: '1.4.4', draft: false)], + [ + $this->merge( + number: 76, + target: str_repeat('d', 40), + ), + $this->merge( + number: 77, + target: str_repeat('e', 40), + ), + ], + ); + } + + #[Test] + public function test_ignores_unrelated_orphan_tag(): void + { + self::assertSame( + null, + RecoverySelector::select( + [ + new Tag( + name: '9.9.9', + target: str_repeat('b', 40), + ), + ], + [$this->release(tag: '1.4.4', draft: false)], + [$this->merge(body: 'No automation marker')], + ), + ); + } + + #[Test] + public function test_ignores_tag_for_unmarked_or_multi_file_pull_request(): void + { + $target = str_repeat('a', 40); + + self::assertSame( + null, + RecoverySelector::select( + [new Tag(name: '1.4.5', target: $target)], + [$this->release(tag: '1.4.4', draft: false)], + [ + $this->merge(body: 'No automation marker'), + $this->merge(files: ['Dockerfile', 'README.md']), + ], + ), + ); + } + + #[Test] + public function test_fails_closed_for_multiple_recoverable_releases(): void + { + $first = str_repeat('a', 40); + $second = str_repeat('b', 40); + $this->expectException(RecoveryException::class); + + RecoverySelector::select( + [ + new Tag(name: '1.4.5', target: $first), + new Tag(name: '1.4.6', target: $second), + ], + [$this->release(tag: '1.4.4', draft: false)], + [ + $this->merge(target: $first), + $this->merge(number: 76, target: $second), + ], + ); + } + + #[Test] + public function test_does_not_resume_wrong_target_draft(): void + { + $target = str_repeat('a', 40); + $this->expectException(RecoveryException::class); + + RecoverySelector::select( + [new Tag(name: '1.4.5', target: $target)], + [ + $this->release(tag: '1.4.4', draft: false), + $this->release(target: str_repeat('b', 40)), + ], + [$this->merge()], + ); + } + + /** + * @param list $files + */ + private function merge( + int $number = 75, + ?string $target = null, + ?string $head = null, + ?string $parent = null, + ?string $testedBase = null, + ?string $body = null, + array $files = ['Dockerfile'], + ): Merge { + $target ??= str_repeat('a', 40); + $head ??= str_repeat('b', 40); + $parent ??= str_repeat('c', 40); + $proof = $testedBase ?? $parent; + + return new Merge( + number: $number, + target: $target, + head: $head, + parents: [$parent], + base: 'main', + branch: 'automation/dependencies-100-1', + body: $body ?? ( + Merge::MARKER . PHP_EOL + . "" . PHP_EOL + . "" + ), + files: $files, + state: 'merged', + ); + } + + private function release( + int $identifier = 10, + string $tag = '1.4.5', + ?string $target = null, + int $pull = 75, + bool $draft = true, + ): Recovery { + $target ??= str_repeat('a', 40); + + return new Recovery( + identifier: $identifier, + tag: $tag, + target: $target, + pull: $pull, + draft: $draft, + prerelease: false, + body: Merge::MARKER . PHP_EOL + . "" . PHP_EOL + . "", + ); + } + + private function assertCandidate( + ?Candidate $candidate, + ?string $tag, + string $target, + int $pull, + ?int $draft, + ): void { + if ($candidate === null) { + self::fail('Expected a recovery candidate'); + } + + self::assertSame($tag, $candidate->tag); + self::assertSame($target, $candidate->target); + self::assertSame($pull, $candidate->pull); + self::assertSame($draft, $candidate->draft); + } +} diff --git a/.github/scripts/tests/Unit/Automation/TargetTest.php b/.github/scripts/tests/Unit/Automation/TargetTest.php new file mode 100644 index 0000000..aea9b1c --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/TargetTest.php @@ -0,0 +1,56 @@ +expectNotToPerformAssertions(); + + TargetValidator::validateTag( + new Tag(name: '1.4.5', target: 'merged-head'), + expectedName: '1.4.5', + expectedTarget: 'merged-head', + ); + TargetValidator::validateRelease( + new Release(tag: '1.4.5', target: 'merged-head'), + expectedTag: '1.4.5', + expectedTarget: 'merged-head', + ); + } + + #[Test] + public function test_rejects_tag_target_mismatch(): void + { + $this->expectException(TargetMismatchException::class); + + TargetValidator::validateTag( + new Tag(name: '1.4.5', target: 'wrong-head'), + expectedName: '1.4.5', + expectedTarget: 'merged-head', + ); + } + + #[Test] + public function test_rejects_release_target_mismatch(): void + { + $this->expectException(TargetMismatchException::class); + + TargetValidator::validateRelease( + new Release(tag: '1.4.5', target: 'wrong-head'), + expectedTag: '1.4.5', + expectedTarget: 'merged-head', + ); + } +} diff --git a/.github/scripts/tests/Unit/Automation/VersionTest.php b/.github/scripts/tests/Unit/Automation/VersionTest.php new file mode 100644 index 0000000..fc8dd4c --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/VersionTest.php @@ -0,0 +1,195 @@ +major); + self::assertSame('9223372036854775808', $version->minor); + self::assertSame('99999999999999999999', $version->patch); + } + + #[Test] + public function test_orders_stable_versions_semantically(): void + { + self::assertSame( + ['1.2.99', '1.10.0', '2.0.0'], + array_map( + static fn (Version $version): string => (string) $version, + Version::stable([ + '1.10.0', + '2.0.0', + '1.2.99', + '1.10.0', + ]), + ), + ); + } + + #[Test] + public function test_orders_adjacent_components_above_integer_range(): void + { + self::assertSame( + [ + '1.18446744073709551616.0', + '1.18446744073709551617.0', + '18446744073709551616.0.0', + ], + array_map( + static fn (Version $version): string => (string) $version, + Version::stable([ + '18446744073709551616.0.0', + '1.18446744073709551617.0', + '1.18446744073709551616.0', + ]), + ), + ); + } + + #[Test] + public function test_ignores_nonstable_and_prefixed_tags(): void + { + self::assertSame( + ['0.0.0', '12.34.56'], + array_map( + static fn (Version $version): string => (string) $version, + Version::stable([ + '0.0.0', + '12.34.56', + 'v12.34.57', + '12.34.57-rc.1', + '12.34', + '12.34.57+build', + '01.2.3', + '1.02.3', + '1.2.03', + '', + ]), + ), + ); + } + + #[Test] + public function test_next_patch_uses_semantic_maximum_of_all_remote_tags(): void + { + self::assertSame( + '2.0.1', + (string) Version::next([ + '1.999.999', + '2.0.0', + 'v9.0.0', + '9.0.0-rc.1', + ]), + ); + } + + #[Test] + public function test_next_patch_carries_without_an_integer_ceiling(): void + { + self::assertSame( + '1.2.100000000000000000000', + (string) Version::next([ + '1.2.99999999999999999999', + ]), + ); + } + + #[Test] + public function test_latest_version_requires_a_stable_remote_tag(): void + { + $this->expectException(VersionMissingException::class); + + Version::latest(['v1.0.0', '1.0.0-rc.1']); + } + + #[Test] + public function test_resumes_newest_tag_newer_than_latest_release(): void + { + $tags = ['1.4.1', '1.4.2', '1.4.3', '1.4.4']; + $releases = ['1.4.1', '1.4.3']; + + self::assertSame( + '1.4.4', + (string) Version::unreleased($tags, $releases), + ); + self::assertSame( + '1.4.4', + (string) Version::candidate($tags, $releases), + ); + } + + #[Test] + public function test_ignores_unreleased_holes_older_than_latest_release(): void + { + self::assertSame( + null, + Version::unreleased( + ['1.4.1', '1.4.2', '1.4.3'], + ['1.4.1', '1.4.3'], + ), + ); + } + + #[Test] + public function test_computes_new_patch_when_every_newer_tag_is_released(): void + { + self::assertSame( + '1.4.5', + (string) Version::candidate( + ['1.4.3', '1.4.4'], + ['1.4.3', '1.4.4'], + ), + ); + } + + #[Test] + public function test_recomputes_after_collision_from_refreshed_remote_tags(): void + { + self::assertSame( + '1.4.8', + (string) Version::afterCollision( + ['1.4.4', '1.4.5', '1.4.7'], + '1.4.5', + ), + ); + } + + #[Test] + public function test_recomputes_unbounded_patch_after_collision(): void + { + self::assertSame( + '1.4.18446744073709551618', + (string) Version::afterCollision( + [ + '1.4.18446744073709551616', + '1.4.18446744073709551617', + ], + '1.4.18446744073709551616', + ), + ); + } + + #[Test] + public function test_rejects_malformed_collision_tag(): void + { + $this->expectException(VersionInvalidException::class); + + Version::afterCollision(['1.4.4'], 'v1.4.5'); + } +} diff --git a/.github/scripts/tests/Unit/Automation/WorkflowTest.php b/.github/scripts/tests/Unit/Automation/WorkflowTest.php new file mode 100644 index 0000000..106aaa2 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/WorkflowTest.php @@ -0,0 +1,255 @@ +start(); + $expected = $this->workflowRun( + identifier: 8, + created: $start->modify('+1 second'), + ); + $runs = [ + $this->workflowRun(identifier: 1, workflow: 'Dive Test'), + $this->workflowRun(identifier: 2, event: 'pull_request'), + $this->workflowRun(identifier: 3, head: 'other-head'), + $this->workflowRun(identifier: 4, branch: 'feature'), + $this->workflowRun( + identifier: 5, + created: $start->modify('-1 microsecond'), + ), + $expected, + ]; + + self::assertSame( + $expected, + RunEvaluator::select( + $runs, + workflow: 'Build and Push', + event: 'push', + head: 'approved-head', + branch: 'main', + created: $start, + ), + ); + } + + #[Test] + public function test_selects_newest_run_then_newest_rerun_attempt(): void + { + $earlier = $this->start()->modify('+1 second'); + $later = $this->start()->modify('+2 seconds'); + $expected = $this->workflowRun( + identifier: 20, + created: $later, + attempt: 2, + ); + $runs = [ + $this->workflowRun( + identifier: 10, + created: $earlier, + attempt: 3, + ), + $this->workflowRun( + identifier: 20, + created: $later, + attempt: 1, + conclusion: 'failure', + ), + $expected, + ]; + + self::assertSame( + $expected, + RunEvaluator::select( + $runs, + workflow: 'Build and Push', + event: 'push', + head: 'approved-head', + branch: 'main', + created: $this->start(), + ), + ); + } + + #[Test] + public function test_reports_missing_run_before_deadline(): void + { + self::assertSame(WorkflowState::Missing, $this->state([])); + } + + #[Test] + public function test_reports_successful_run(): void + { + self::assertSame( + WorkflowState::Succeeded, + $this->state([$this->workflowRun()]), + ); + } + + #[Test] + public function test_does_not_accept_branch_run_for_tag_release(): void + { + $start = $this->start(); + $state = RunEvaluator::state( + [$this->workflowRun(branch: 'main')], + workflow: 'Build and Push', + event: 'push', + head: 'approved-head', + branch: '1.4.5', + created: $start, + deadline: Deadline::after($start, 1_800), + now: $start, + ); + + self::assertSame(WorkflowState::Missing, $state); + } + + #[Test] + public function test_reports_failed_run(): void + { + self::assertSame( + WorkflowState::Failed, + $this->state([$this->workflowRun(conclusion: 'failure')]), + ); + } + + #[Test] + public function test_reports_cancelled_run(): void + { + self::assertSame( + WorkflowState::Cancelled, + $this->state([$this->workflowRun(conclusion: 'cancelled')]), + ); + } + + #[Test] + public function test_reports_run_timeout_conclusion(): void + { + self::assertSame( + WorkflowState::TimedOut, + $this->state([$this->workflowRun(conclusion: 'timed_out')]), + ); + } + + #[Test] + public function test_reports_pending_run_before_deadline(): void + { + self::assertSame( + WorkflowState::Pending, + $this->state( + [ + $this->workflowRun( + status: 'in_progress', + conclusion: null, + ), + ], + $this->start()->modify('+29 minutes'), + ), + ); + } + + #[Test] + public function test_times_out_pending_run_at_deadline(): void + { + self::assertSame( + WorkflowState::TimedOut, + $this->state( + [ + $this->workflowRun( + status: 'queued', + conclusion: null, + ), + ], + $this->start()->modify('+30 minutes'), + ), + ); + } + + #[Test] + public function test_times_out_missing_run_at_deadline(): void + { + self::assertSame( + WorkflowState::TimedOut, + $this->state( + [], + $this->start()->modify('+30 minutes'), + ), + ); + } + + #[Test] + public function test_preserves_terminal_failure_after_deadline(): void + { + self::assertSame( + WorkflowState::Failed, + $this->state( + [$this->workflowRun(conclusion: 'failure')], + $this->start()->modify('+31 minutes'), + ), + ); + } + + private function workflowRun( + int $identifier = 1, + string $workflow = 'Build and Push', + string $event = 'push', + string $head = 'approved-head', + string $branch = 'main', + ?DateTimeImmutable $created = null, + int $attempt = 1, + string $status = 'completed', + ?string $conclusion = 'success', + ): Run { + return new Run( + identifier: $identifier, + workflow: $workflow, + event: $event, + head: $head, + branch: $branch, + created: $created ?? $this->start(), + attempt: $attempt, + status: $status, + conclusion: $conclusion, + ); + } + + /** + * @param list $runs + */ + private function state( + array $runs, + ?DateTimeImmutable $now = null, + ): WorkflowState { + $start = $this->start(); + + return RunEvaluator::state( + $runs, + workflow: 'Build and Push', + event: 'push', + head: 'approved-head', + branch: 'main', + created: $start, + deadline: Deadline::after($start, 1_800), + now: $now ?? $start, + ); + } + + private function start(): DateTimeImmutable + { + return new DateTimeImmutable('2026-07-24T08:00:00+00:00'); + } +} diff --git a/.github/scripts/tests/Unit/Command/ResultTest.php b/.github/scripts/tests/Unit/Command/ResultTest.php new file mode 100644 index 0000000..761c259 --- /dev/null +++ b/.github/scripts/tests/Unit/Command/ResultTest.php @@ -0,0 +1,19 @@ +succeeded()); + self::assertFalse((new Result(1, '', 'error'))->succeeded()); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/CatalogTest.php b/.github/scripts/tests/Unit/Dependency/CatalogTest.php new file mode 100644 index 0000000..067d9e9 --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/CatalogTest.php @@ -0,0 +1,121 @@ +dependencies(); + $names = array_map( + static fn (Dependency $dependency): string => $dependency->name, + $dependencies, + ); + $expected = array_keys(Fixture::CURRENT); + sort($names, SORT_STRING); + sort($expected, SORT_STRING); + + self::assertSame(13, count($dependencies)); + self::assertSame($expected, $names); + } + + public function test_uses_exact_dockerfile_sources(): void + { + $expected = [ + 'brotli' => 'https://github.com/kjdev/php-ext-brotli.git', + 'imagick' => 'https://github.com/imagick/imagick', + 'lz4' => 'https://github.com/kjdev/php-ext-lz4.git', + 'maxminddb' => 'https://github.com/maxmind/MaxMind-DB-Reader-php.git', + 'mongodb' => 'https://github.com/mongodb/mongo-php-driver.git', + 'redis' => 'https://github.com/phpredis/phpredis.git', + 'scrypt' => 'https://github.com/DomBlack/php-scrypt.git', + 'snappy' => 'https://github.com/kjdev/php-ext-snappy.git', + 'swoole' => 'https://github.com/swoole/swoole-src.git', + 'xdebug' => 'https://github.com/xdebug/xdebug', + 'yaml' => 'https://github.com/php/pecl-file_formats-yaml', + 'zstd' => 'https://github.com/kjdev/php-ext-zstd.git', + ]; + $actual = []; + $protobuf = null; + + foreach (Catalog::create()->dependencies() as $dependency) { + if ($dependency->source instanceof Git) { + $actual[$dependency->name] = $dependency->source->url(); + } elseif ($dependency->name === 'protobuf') { + $protobuf = $dependency; + } + } + + self::assertSame($expected, $actual); + if (! $protobuf instanceof Dependency) { + self::fail('Expected protobuf dependency'); + } + + self::assertSame(true, $protobuf->source instanceof PECL); + self::assertSame(Catalog::PECL_RELEASES, $protobuf->source->url()); + } + + public function test_source_definitions_are_immutable(): void + { + self::assertSame(true, (new ReflectionClass(Git::class))->isReadOnly()); + self::assertSame(true, (new ReflectionClass(PECL::class))->isReadOnly()); + } + + public function test_domain_classes_are_in_matching_files(): void + { + $symbols = [ + Application::class, + Catalog::class, + Change::class, + Dependency::class, + Dockerfile::class, + Exception::class, + Fetcher::class, + Pin::class, + Plan::class, + Reporter::class, + Resolver::class, + Selector::class, + Source::class, + Git::class, + PECL::class, + Updater::class, + Version::class, + ]; + + foreach ($symbols as $symbol) { + $reflection = new ReflectionClass($symbol); + $path = $reflection->getFileName(); + self::assertSame(true, is_string($path)); + self::assertSame( + $reflection->getShortName() . '.php', + basename($path), + ); + } + } +} diff --git a/.github/scripts/tests/Unit/Dependency/ConsoleTest.php b/.github/scripts/tests/Unit/Dependency/ConsoleTest.php new file mode 100644 index 0000000..23281de --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/ConsoleTest.php @@ -0,0 +1,98 @@ +console( + $path, + new Discovery(digest: Fixture::NEW_DIGEST), + ); + + try { + $report = $console->execute(['--dry-run']); + + self::assertStringContainsString('**Updates:** 1', $report); + self::assertSame(Fixture::dockerfile(), file_get_contents($path)); + } finally { + unlink($path); + rmdir($directory); + } + } + + public function test_rejects_unknown_arguments(): void + { + $this->expectException(UsageException::class); + $this->expectExceptionMessage( + "Unknown dependency updater argument '--unknown'", + ); + + $this->console('/tmp/Dockerfile', new Discovery()) + ->execute(['--unknown']); + } + + public function test_renders_concise_help_without_updating(): void + { + self::assertSame( + Console::USAGE, + $this->console('/tmp/Dockerfile', new Discovery()) + ->execute(['--help']), + ); + } + + public function test_rejects_help_combined_with_other_arguments(): void + { + $this->expectException(UsageException::class); + $this->expectExceptionMessage( + '--help cannot be combined with other arguments', + ); + + $this->console('/tmp/Dockerfile', new Discovery()) + ->execute(['--help', '--dry-run']); + } + + private function console( + string $path, + Discovery $discovery, + ): Console { + $catalog = Catalog::create(); + $application = new Application( + $catalog, + new Dockerfile(), + new Resolver($discovery, $discovery), + new Selector(), + ); + + return new Console( + new Updater($application), + new Reporter(), + $path, + ); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/Discovery.php b/.github/scripts/tests/Unit/Dependency/Discovery.php new file mode 100644 index 0000000..669b85b --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/Discovery.php @@ -0,0 +1,99 @@ +> */ + public array $commands = []; + + /** @var list */ + public array $urls = []; + + /** + * @param array> $releases + * @param list $pecl + */ + public function __construct( + private readonly string $digest = Fixture::OLD_DIGEST, + private readonly array $releases = [], + private readonly array $pecl = [], + private readonly ?string $digestOutput = null, + ) { + } + + /** + * @param list $command + */ + #[Override] + public function run(array $command, bool $check = true): Result + { + $this->commands[] = $command; + if ($command === [ + 'docker', + 'buildx', + 'imagetools', + 'inspect', + Catalog::BASE, + ]) { + $output = $this->digestOutput + ?? "Name: docker.io/library/" . Catalog::BASE + . "\nDigest: {$this->digest}\n"; + + return new Result(0, $output, ''); + } + + if (array_slice($command, 0, 4) === [ + 'git', + 'ls-remote', + '--tags', + '--refs', + ]) { + $url = $command[4]; + foreach (Catalog::create()->dependencies() as $dependency) { + if ( + $dependency->source instanceof Git + && $dependency->source->url() === $url + ) { + $spellings = $this->releases[$dependency->name] + ?? [Fixture::CURRENT[$dependency->name]]; + + return new Result( + 0, + Fixture::gitTags(...$spellings), + '', + ); + } + } + } + + throw new LogicException( + 'Unexpected command: ' . implode(' ', $command), + ); + } + + #[Override] + public function fetch(string $url): string + { + $this->urls[] = $url; + if ($url !== Catalog::PECL_RELEASES) { + throw new LogicException("Unexpected URL: {$url}"); + } + + $pecl = $this->pecl === [] + ? [[Fixture::CURRENT['protobuf'], 'stable']] + : $this->pecl; + + return Fixture::peclReleases(...$pecl); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/DockerfileTest.php b/.github/scripts/tests/Unit/Dependency/DockerfileTest.php new file mode 100644 index 0000000..75c9a6f --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/DockerfileTest.php @@ -0,0 +1,422 @@ +pins( + Fixture::dockerfile(), + Catalog::create(), + ); + $names = array_map( + static fn (Pin $pin): string => $pin->name, + array_slice($pins, 1), + ); + $expected = array_keys(Fixture::CURRENT); + sort($names, SORT_STRING); + sort($expected, SORT_STRING); + + self::assertSame(14, count($pins)); + self::assertSame(Catalog::BASE, $pins[0]->name); + self::assertSame(Fixture::OLD_DIGEST, $pins[0]->current); + self::assertSame($expected, $names); + } + + public function test_real_dockerfile_declarations_match_independent_contract(): void + { + $path = dirname(__DIR__, 5) . '/Dockerfile'; + $content = file_get_contents($path); + self::assertSame(true, is_string($content)); + + $count = preg_match_all( + '/^[ \t]*(?:(?:ARG|ENV)[ \t]+)?' + . '((?:BASE_IMAGE|PHP_[A-Z0-9_]+_VERSION))[ \t]*=/m', + $content, + $matches, + ); + self::assertSame(14, $count); + + $declarations = array_values(array_unique($matches[1])); + $expectedDeclarations = Fixture::EXPECTED_DOCKERFILE_DECLARATIONS; + sort($declarations, SORT_STRING); + sort($expectedDeclarations, SORT_STRING); + self::assertSame($expectedDeclarations, $declarations); + + $pins = (new Dockerfile())->pins($content, Catalog::create()); + $names = array_map( + static fn (Pin $pin): string => $pin->name, + $pins, + ); + $expectedNames = [Catalog::BASE, ...array_keys(Fixture::CURRENT)]; + sort($names, SORT_STRING); + sort($expectedNames, SORT_STRING); + self::assertSame($expectedNames, $names); + self::assertSame( + 1, + preg_match('/\Asha256:[0-9a-f]{64}\z/', $pins[0]->current), + ); + } + + public function test_rejects_unknown_extension_declaration(): void + { + $this->assertFailure( + str_replace( + "ENV \\\n", + "ENV \\\n PHP_UNKNOWN_VERSION=\"1.2.3\" \\\n", + Fixture::dockerfile(), + ), + 'Unknown PHP extension version declaration: PHP_UNKNOWN_VERSION', + ); + + $this->assertFailure( + Fixture::dockerfile() + . "\nENV PHP_second_VERSION=\"1.2.3\" " + . "PHP_THIRD_VERSION=\"2.3.4\"\n", + 'Unknown PHP extension version declarations: ' + . 'PHP_THIRD_VERSION, PHP_second_VERSION', + ); + + $this->assertFailure( + Fixture::dockerfile() . "\nARG PHP_ARGUMENT_VERSION\n", + 'Unknown PHP extension version declaration: PHP_ARGUMENT_VERSION', + ); + } + + public function test_rejects_missing_declaration(): void + { + $content = str_replace( + ' PHP_REDIS_VERSION="' . Fixture::CURRENT['redis'] . "\" \\\n", + '', + Fixture::dockerfile(), + ); + + $this->assertFailure( + $content, + 'Expected exactly one PHP_REDIS_VERSION declaration, found 0', + ); + } + + public function test_rejects_duplicate_declaration(): void + { + $content = Fixture::dockerfile() + . "\nENV PHP_REDIS_VERSION=\"" + . Fixture::CURRENT['redis'] + . "\"\n"; + + $this->assertFailure( + $content, + 'Expected exactly one PHP_REDIS_VERSION declaration, found 2', + ); + } + + public function test_rejects_missing_and_duplicate_base_declarations(): void + { + $declaration = 'ARG BASE_IMAGE="' . Catalog::BASE . '@' + . Fixture::OLD_DIGEST . "\"\n"; + $missing = str_replace( + $declaration, + '', + Fixture::dockerfile(), + ); + $this->assertFailure( + $missing, + 'Expected exactly one ARG BASE_IMAGE declaration, found 0', + ); + + $this->assertFailure( + Fixture::dockerfile() . $declaration, + 'Expected exactly one ARG BASE_IMAGE declaration, found 2', + ); + } + + public function test_rejects_invalid_pin_spelling(): void + { + $content = str_replace( + 'PHP_YAML_VERSION="' . Fixture::CURRENT['yaml'] . '"', + 'PHP_YAML_VERSION="2.4.0RC1"', + Fixture::dockerfile(), + ); + + $this->assertFailure( + $content, + 'PHP_YAML_VERSION must be an exact stable ' + . 'v?MAJOR.MINOR.PATCH version', + ); + } + + public function test_rejects_invalid_base_digest(): void + { + $content = str_replace( + Fixture::OLD_DIGEST, + 'sha256:ABC', + Fixture::dockerfile(), + ); + + $this->assertFailure( + $content, + 'ARG BASE_IMAGE must pin ' . Catalog::BASE + . ' to a lowercase sha256 digest', + ); + } + + public function test_resolves_lowercase_multiarch_digest_through_buildx(): void + { + $discovery = new Discovery(digest: Fixture::NEW_DIGEST); + $resolver = new Resolver($discovery, $discovery); + + self::assertSame(Fixture::NEW_DIGEST, $resolver->digest()); + self::assertSame( + [[ + 'docker', + 'buildx', + 'imagetools', + 'inspect', + Catalog::BASE, + ]], + $discovery->commands, + ); + } + + public function test_rejects_invalid_or_ambiguous_digest_output(): void + { + foreach ([ + 'Digest: sha256:' . str_repeat('A', 64) . "\n", + 'Digest: ' . Fixture::OLD_DIGEST . "\n" + . 'Digest: ' . Fixture::NEW_DIGEST . "\n", + ] as $output) { + $discovery = new Discovery(digestOutput: $output); + $resolver = new Resolver($discovery, $discovery); + + try { + $resolver->digest(); + self::fail('Expected invalid digest output to fail'); + } catch (Exception $exception) { + self::assertSame( + 'Expected one lowercase sha256 digest for ' + . Catalog::BASE, + $exception->getMessage(), + ); + } + } + } + + public function test_plans_updates_without_touching_references(): void + { + $discovery = new Discovery( + digest: Fixture::NEW_DIGEST, + releases: [ + 'redis' => [ + Fixture::CURRENT['redis'], + '6.3.1', + 'v6.3.1', + '7.0.0', + ], + 'swoole' => [ + Fixture::CURRENT['swoole'], + '6.2.1', + 'v6.2.1', + ], + ], + pecl: [ + [Fixture::CURRENT['protobuf'], 'stable'], + ['5.34.1', 'stable'], + ['5.35.0RC1', 'beta'], + ['6.0.0', 'stable'], + ], + ); + $plan = $this->application($discovery)->plan( + Fixture::dockerfile(), + ); + + self::assertSame(true, $plan->changed()); + self::assertSame( + true, + str_contains( + $plan->content, + 'ARG BASE_IMAGE="' . Catalog::BASE . '@' + . Fixture::NEW_DIGEST . '"', + ), + ); + self::assertSame( + true, + str_contains($plan->content, 'PHP_REDIS_VERSION="6.3.1"'), + ); + self::assertSame( + true, + str_contains($plan->content, 'PHP_PROTOBUF_VERSION="5.34.1"'), + ); + self::assertSame( + true, + str_contains($plan->content, 'PHP_SWOOLE_VERSION="v6.2.1"'), + ); + self::assertSame( + true, + str_contains($plan->content, 'RUN echo "$PHP_REDIS_VERSION"'), + ); + } + + public function test_noop_plan_preserves_content_exactly(): void + { + $content = Fixture::dockerfile(); + $plan = $this->application(new Discovery())->plan($content); + + self::assertSame(false, $plan->changed()); + self::assertSame($content, $plan->content); + } + + public function test_injects_every_external_interaction(): void + { + $discovery = new Discovery(); + $catalog = Catalog::create(); + $this->application($discovery, $catalog)->plan( + Fixture::dockerfile(), + ); + $gitSources = count(array_filter( + $catalog->dependencies(), + static fn (Dependency $dependency): bool => $dependency->source instanceof Git, + )); + + self::assertSame(1 + $gitSources, count($discovery->commands)); + self::assertSame([Catalog::PECL_RELEASES], $discovery->urls); + + foreach ($catalog->dependencies() as $dependency) { + if (! $dependency->source instanceof Git) { + continue; + } + + self::assertSame( + true, + in_array( + [ + 'git', + 'ls-remote', + '--tags', + '--refs', + $dependency->source->url(), + ], + $discovery->commands, + true, + ), + ); + } + } + + public function test_dry_run_does_not_mutate_dockerfile_or_siblings(): void + { + $discovery = new Discovery( + digest: Fixture::NEW_DIGEST, + releases: [ + 'redis' => [Fixture::CURRENT['redis'], '6.3.1'], + ], + ); + $directory = $this->directory(); + $path = "{$directory}/Dockerfile"; + $sibling = "{$directory}/keep.txt"; + $original = Fixture::dockerfile(); + file_put_contents($path, $original); + file_put_contents($sibling, 'unchanged'); + + try { + $plan = (new Updater($this->application($discovery))) + ->update($path, true); + + self::assertSame(true, $plan->changed()); + self::assertSame($original, file_get_contents($path)); + self::assertSame('unchanged', file_get_contents($sibling)); + } finally { + unlink($path); + unlink($sibling); + rmdir($directory); + } + } + + public function test_update_mutates_only_dockerfile(): void + { + $directory = $this->directory(); + $path = "{$directory}/Dockerfile"; + $sibling = "{$directory}/keep.txt"; + file_put_contents($path, Fixture::dockerfile()); + file_put_contents($sibling, 'unchanged'); + + try { + $plan = (new Updater($this->application( + new Discovery(digest: Fixture::NEW_DIGEST), + )))->update($path); + + self::assertSame($plan->content, file_get_contents($path)); + self::assertSame('unchanged', file_get_contents($sibling)); + } finally { + unlink($path); + unlink($sibling); + rmdir($directory); + } + } + + public function test_rejects_non_dockerfile_target(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + 'The update target must be named Dockerfile', + ); + + (new Updater($this->application(new Discovery()))) + ->update('/tmp/not-a-dockerfile'); + } + + private function application( + Discovery $discovery, + ?Catalog $catalog = null, + ): Application { + $catalog ??= Catalog::create(); + + return new Application( + $catalog, + new Dockerfile(), + new Resolver($discovery, $discovery), + new Selector(), + ); + } + + private function assertFailure(string $content, string $message): void + { + try { + (new Dockerfile())->pins($content, Catalog::create()); + self::fail('Expected Dockerfile validation to fail'); + } catch (Exception $exception) { + self::assertSame($message, $exception->getMessage()); + } + } + + private function directory(): string + { + $path = sys_get_temp_dir() + . '/docker-base-dependencies-' + . bin2hex(random_bytes(8)); + if (! mkdir($path)) { + self::fail("Unable to create temporary directory: {$path}"); + } + + return $path; + } +} diff --git a/.github/scripts/tests/Unit/Dependency/Fixture.php b/.github/scripts/tests/Unit/Dependency/Fixture.php new file mode 100644 index 0000000..001f10b --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/Fixture.php @@ -0,0 +1,117 @@ + '0.18.3', + 'imagick' => '3.8.1', + 'lz4' => '0.6.0', + 'maxminddb' => 'v1.13.1', + 'mongodb' => '2.2.1', + 'protobuf' => '5.34.0', + 'redis' => '6.3.0', + 'scrypt' => '2.0.1', + 'snappy' => '0.2.3', + 'swoole' => 'v6.2.0', + 'xdebug' => '3.5.1', + 'yaml' => '2.3.0', + 'zstd' => '0.15.2', + ]; + + public const array DECLARATIONS = [ + ['brotli', 'PHP_BROTLI_VERSION'], + ['imagick', 'PHP_IMAGICK_VERSION'], + ['lz4', 'PHP_LZ4_VERSION'], + ['maxminddb', 'PHP_MAXMINDDB_VERSION'], + ['mongodb', 'PHP_MONGODB_VERSION'], + ['protobuf', 'PHP_PROTOBUF_VERSION'], + ['redis', 'PHP_REDIS_VERSION'], + ['scrypt', 'PHP_SCRYPT_VERSION'], + ['snappy', 'PHP_SNAPPY_VERSION'], + ['swoole', 'PHP_SWOOLE_VERSION'], + ['yaml', 'PHP_YAML_VERSION'], + ['zstd', 'PHP_ZSTD_VERSION'], + ]; + + public const array EXPECTED_DOCKERFILE_DECLARATIONS = [ + 'BASE_IMAGE', + 'PHP_BROTLI_VERSION', + 'PHP_IMAGICK_VERSION', + 'PHP_LZ4_VERSION', + 'PHP_MAXMINDDB_VERSION', + 'PHP_MONGODB_VERSION', + 'PHP_PROTOBUF_VERSION', + 'PHP_REDIS_VERSION', + 'PHP_SCRYPT_VERSION', + 'PHP_SNAPPY_VERSION', + 'PHP_SWOOLE_VERSION', + 'PHP_XDEBUG_VERSION', + 'PHP_YAML_VERSION', + 'PHP_ZSTD_VERSION', + ]; + + public const string OLD_DIGEST = 'sha256:1111111111111111111111111111111111111111111111111111111111111111'; + + public const string NEW_DIGEST = 'sha256:2222222222222222222222222222222222222222222222222222222222222222'; + + public static function dockerfile(): string + { + $lines = [ + 'ARG BASE_IMAGE="' . Catalog::BASE . '@' . self::OLD_DIGEST . '"', + '', + 'FROM $BASE_IMAGE AS compile', + '', + 'ENV \\', + ]; + + foreach (self::DECLARATIONS as $index => [$name, $variable]) { + $suffix = $index < count(self::DECLARATIONS) - 1 ? ' \\' : ''; + $lines[] = " {$variable}=\"" . self::CURRENT[$name] . "\"{$suffix}"; + } + + array_push( + $lines, + '', + '# References should never be rewritten:', + 'RUN echo "$PHP_REDIS_VERSION"', + '', + 'FROM compile AS xdebug-build', + '', + 'ENV PHP_XDEBUG_VERSION="' . self::CURRENT['xdebug'] . '"', + '', + ); + + return implode("\n", $lines); + } + + public static function gitTags(string ...$spellings): string + { + $output = ''; + foreach ($spellings as $spelling) { + $output .= str_repeat('a', 40) . "\trefs/tags/{$spelling}\n"; + } + + return $output; + } + + /** + * @param array{string, string} ...$releases + */ + public static function peclReleases(array ...$releases): string + { + $entries = ''; + foreach ($releases as [$version, $state]) { + $entries .= "{$version}{$state}"; + } + + return '' + . '' + . "

protobuf

{$entries}
"; + } +} diff --git a/.github/scripts/tests/Unit/Dependency/ParityTest.php b/.github/scripts/tests/Unit/Dependency/ParityTest.php new file mode 100644 index 0000000..9ad1fdd --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/ParityTest.php @@ -0,0 +1,146 @@ +application(new Discovery( + digest: Fixture::NEW_DIGEST, + releases: [ + 'redis' => [Fixture::CURRENT['redis'], '6.3.1'], + 'swoole' => [ + Fixture::CURRENT['swoole'], + '6.2.1', + 'v6.2.1', + ], + ], + pecl: [ + [Fixture::CURRENT['protobuf'], 'stable'], + ['5.34.1', 'stable'], + ['5.35.0RC1', 'beta'], + ], + ))->plan($content); + $noop = $this->application(new Discovery())->plan($content); + + $directory = sys_get_temp_dir() + . '/docker-base-parity-' + . bin2hex(random_bytes(8)); + if (! mkdir($directory)) { + self::fail("Unable to create parity directory: {$directory}"); + } + $path = "{$directory}/Dockerfile"; + $sibling = "{$directory}/keep.txt"; + file_put_contents($path, $content); + file_put_contents($sibling, 'unchanged'); + + try { + $dryDiscovery = new Discovery(digest: Fixture::NEW_DIGEST); + $dry = (new Updater( + $this->application($dryDiscovery), + ))->update($path, true); + $selected = []; + foreach ($updated->changes as $change) { + $selected[$change->name] = $change->latest; + } + ksort($selected, SORT_STRING); + + $result = [ + 'dry_changed' => $dry->changed(), + 'dry_dockerfile' => base64_encode( + (string) file_get_contents($path), + ), + 'dry_sibling' => base64_encode( + (string) file_get_contents($sibling), + ), + 'noop' => base64_encode($noop->content), + 'noop_report' => base64_encode( + (new Reporter())->render($noop), + ), + 'selected' => $selected, + 'update' => base64_encode($updated->content), + 'update_report' => base64_encode( + (new Reporter())->render($updated), + ), + ]; + ksort($result, SORT_STRING); + $record = json_encode( + $result, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, + ); + + $expected = $this->expected(); + self::assertSame($expected['bytes'], strlen($record)); + self::assertSame( + $expected['sha256'], + hash('sha256', $record), + ); + } finally { + unlink($path); + unlink($sibling); + rmdir($directory); + } + } + + private function application(Discovery $discovery): Application + { + return new Application( + Catalog::create(), + new Dockerfile(), + new Resolver($discovery, $discovery), + new Selector(), + ); + } + + /** + * @return array{bytes: int, sha256: string} + */ + private function expected(): array + { + $document = file_get_contents( + dirname(__DIR__, 2) . '/equivalence.json', + ); + if (! is_string($document)) { + self::fail('Unable to read parity evidence'); + } + try { + $equivalence = json_decode( + $document, + true, + 512, + JSON_THROW_ON_ERROR, + ); + } catch (JsonException $exception) { + self::fail($exception->getMessage()); + } + $expected = is_array($equivalence) + ? ($equivalence['deterministic'] ?? null) + : null; + if ( + ! is_array($expected) + || ! is_int($expected['bytes'] ?? null) + || ! is_string($expected['sha256'] ?? null) + ) { + self::fail('Parity evidence is invalid'); + } + + return [ + 'bytes' => $expected['bytes'], + 'sha256' => $expected['sha256'], + ]; + } +} diff --git a/.github/scripts/tests/Unit/Dependency/ProgramTest.php b/.github/scripts/tests/Unit/Dependency/ProgramTest.php new file mode 100644 index 0000000..216ffcf --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/ProgramTest.php @@ -0,0 +1,106 @@ +program('/tmp/Dockerfile')->execute(['--help']); + + self::assertSame(0, $result->code); + self::assertSame(Console::USAGE . PHP_EOL, $result->output); + self::assertSame('', $result->error); + } + + public function test_returns_usage_errors_on_standard_error(): void + { + $result = $this->program('/tmp/Dockerfile')->execute(['--unknown']); + + self::assertSame(2, $result->code); + self::assertSame('', $result->output); + self::assertSame( + "Error: Unknown dependency updater argument '--unknown'" + . PHP_EOL, + $result->error, + ); + } + + public function test_returns_domain_errors_without_a_stack_trace(): void + { + $result = $this->program('/tmp/not-a-dockerfile') + ->execute([]); + + self::assertSame(1, $result->code); + self::assertSame('', $result->output); + self::assertSame( + 'Error: The update target must be named Dockerfile' . PHP_EOL, + $result->error, + ); + self::assertStringNotContainsString('Stack trace', $result->error); + } + + public function test_returns_a_success_report_on_standard_output(): void + { + $directory = sys_get_temp_dir() + . '/docker-base-program-' + . bin2hex(random_bytes(8)); + if (! mkdir($directory)) { + self::fail( + "Unable to create temporary directory: {$directory}", + ); + } + $path = "{$directory}/Dockerfile"; + self::assertSame( + strlen(Fixture::dockerfile()), + file_put_contents($path, Fixture::dockerfile()), + ); + + try { + $result = $this->program($path)->execute(['--dry-run']); + + self::assertSame(0, $result->code); + self::assertStringContainsString( + '**Updates:** 0', + $result->output, + ); + self::assertSame('', $result->error); + } finally { + unlink($path); + rmdir($directory); + } + } + + private function program(string $path): Program + { + $discovery = new Discovery(); + + return new Program( + new Console( + new Updater( + new Application( + Catalog::create(), + new Dockerfile(), + new Resolver($discovery, $discovery), + new Selector(), + ), + ), + new Reporter(), + $path, + ), + ); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/ReporterTest.php b/.github/scripts/tests/Unit/Dependency/ReporterTest.php new file mode 100644 index 0000000..c30d62d --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/ReporterTest.php @@ -0,0 +1,92 @@ +application(new Discovery( + digest: Fixture::NEW_DIGEST, + releases: [ + 'redis' => [Fixture::CURRENT['redis'], '6.3.1'], + ], + ))->plan(Fixture::dockerfile()); + $report = (new Reporter())->render($plan); + + self::assertSame( + true, + str_starts_with($report, "## Dependency update report\n"), + ); + self::assertSame( + true, + str_contains( + $report, + '| Dependency | Current | Selected | Result |', + ), + ); + self::assertSame( + true, + str_contains( + $report, + '| ' . Catalog::BASE . ' | `' . Fixture::OLD_DIGEST + . '` | `' . Fixture::NEW_DIGEST . '` | Updated |', + ), + ); + self::assertSame( + true, + str_contains( + $report, + '| redis | `' . Fixture::CURRENT['redis'] + . '` | `6.3.1` | Updated |', + ), + ); + self::assertSame(true, str_contains($report, '**Updates:** 2')); + self::assertSame( + true, + str_ends_with($report, 'Dockerfile pins were updated.'), + ); + } + + public function test_renders_explicit_noop_report(): void + { + $plan = $this->application(new Discovery())->plan( + Fixture::dockerfile(), + ); + $report = (new Reporter())->render($plan); + + self::assertSame(true, str_contains($report, '**Updates:** 0')); + self::assertSame( + true, + str_contains( + $report, + 'No dependency updates were found.', + ), + ); + self::assertSame(false, str_contains($report, '| Updated |')); + } + + private function application(Discovery $discovery): Application + { + $catalog = Catalog::create(); + + return new Application( + $catalog, + new Dockerfile(), + new Resolver($discovery, $discovery), + new Selector(), + ); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/ResolverTest.php b/.github/scripts/tests/Unit/Dependency/ResolverTest.php new file mode 100644 index 0000000..2ef1947 --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/ResolverTest.php @@ -0,0 +1,83 @@ +resolver()->git($output), + ); + } + + public function test_filters_pecl_by_stable_state_and_exact_version(): void + { + $document = Fixture::peclReleases( + ['5.35.0RC1', 'beta'], + ['5.35.0', 'stable'], + ['5.34.2', 'stable'], + ['5.36.0', 'beta'], + ['v5.35.1', 'stable'], + ['5.35.2RC1', 'stable'], + ); + + self::assertSame( + ['5.35.0', '5.34.2', 'v5.35.1'], + $this->resolver()->pecl($document), + ); + } + + public function test_rejects_invalid_pecl_xml(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid PECL release XML'); + + $this->resolver()->pecl(''); + } + + public function test_rejects_source_with_no_stable_releases(): void + { + $catalog = Catalog::create(); + $discovery = new Discovery( + pecl: [['5.35.0RC1', 'beta']], + ); + $resolver = new Resolver($discovery, $discovery); + $protobuf = array_values(array_filter( + $catalog->dependencies(), + static fn ($dependency): bool => $dependency->name === 'protobuf', + ))[0]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage( + 'No exact stable releases found for protobuf', + ); + + $resolver->releases($protobuf); + } + + private function resolver(): Resolver + { + $discovery = new Discovery(); + + return new Resolver($discovery, $discovery); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/SourceTest.php b/.github/scripts/tests/Unit/Dependency/SourceTest.php new file mode 100644 index 0000000..7b27384 --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/SourceTest.php @@ -0,0 +1,33 @@ +url(), + ); + self::assertSame( + 'https://pecl.php.net/rest/r/allreleases.xml', + $pecl->url(), + ); + } +} diff --git a/.github/scripts/tests/Unit/Dependency/VersionTest.php b/.github/scripts/tests/Unit/Dependency/VersionTest.php new file mode 100644 index 0000000..1b303cf --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/VersionTest.php @@ -0,0 +1,145 @@ +major); + self::assertSame('2', $version->minor); + self::assertSame('3', $version->patch); + } + + $large = Version::parse('18446744073709551616.2.3'); + self::assertSame(true, $large instanceof Version); + self::assertSame('18446744073709551616', $large->major); + + foreach ([ + 'V1.2.3', + '01.2.3', + '1.02.3', + '1.2.03', + 'v00.0.0', + '1.2', + '1.2.3.4', + 'release-1.2.3', + '1.2.3RC1', + 'v1.2.3-beta.1', + ] as $spelling) { + self::assertSame(null, Version::parse($spelling)); + } + } + + public function test_selects_semantic_maximum_not_lexical_maximum(): void + { + self::assertSame( + '1.10.0', + (new Selector())->select( + '1.2.0', + ['1.2.9', '1.10.0', '1.9.12'], + ), + ); + self::assertSame( + '1.18446744073709551616.0', + (new Selector())->select( + '1.18446744073709551615.0', + ['1.18446744073709551616.0'], + ), + ); + } + + public function test_selects_minor_and_patch_updates(): void + { + $selector = new Selector(); + + self::assertSame( + '1.3.0', + $selector->select('1.2.3', ['1.2.4', '1.3.0']), + ); + self::assertSame( + '1.2.4', + $selector->select('1.2.3', ['1.2.4']), + ); + } + + public function test_ignores_higher_major(): void + { + self::assertSame( + '1.2.3', + (new Selector())->select('1.2.3', ['2.0.0', 'v3.4.5']), + ); + } + + public function test_ignores_prereleases(): void + { + self::assertSame( + '1.2.4', + (new Selector())->select( + '1.2.3', + ['1.2.4RC1', 'v1.3.0-beta.1', '1.2.4'], + ), + ); + } + + public function test_never_downgrades(): void + { + self::assertSame( + '1.5.0', + (new Selector())->select( + '1.5.0', + ['1.4.9', '1.5.0', '2.0.0'], + ), + ); + } + + public function test_preserves_selected_upstream_prefix(): void + { + $selector = new Selector(); + + self::assertSame( + 'v1.3.0', + $selector->select('1.2.3', ['v1.3.0']), + ); + self::assertSame( + '1.3.0', + $selector->select('v1.2.3', ['1.3.0']), + ); + } + + public function test_prefers_current_prefix_for_equivalent_tags(): void + { + $selector = new Selector(); + $releases = ['1.3.0', 'v1.3.0']; + + self::assertSame( + 'v1.3.0', + $selector->select('v1.2.3', $releases), + ); + self::assertSame( + '1.3.0', + $selector->select('1.2.3', $releases), + ); + } + + public function test_rejects_invalid_current_version(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid current version: 1.2.3RC1'); + + (new Selector())->select('1.2.3RC1', ['1.2.4']); + } +} diff --git a/.github/scripts/tests/Unit/Parity/VerifierTest.php b/.github/scripts/tests/Unit/Parity/VerifierTest.php new file mode 100644 index 0000000..e5786cb --- /dev/null +++ b/.github/scripts/tests/Unit/Parity/VerifierTest.php @@ -0,0 +1,127 @@ +evidence(); + + try { + self::assertSame( + 1, + (new Verifier($manifest, $results))->verify(), + ); + } finally { + $this->remove($directory); + } + } + + public function test_rejects_a_mapped_test_that_was_not_executed(): void + { + [$directory, $manifest, $results] = $this->evidence( + executed: false, + ); + + try { + $this->expectException(Exception::class); + $this->expectExceptionMessage('was not executed'); + + (new Verifier($manifest, $results))->verify(); + } finally { + $this->remove($directory); + } + } + + #[DataProvider('unsuccessfulStates')] + public function test_rejects_an_unsuccessful_mapped_test( + string $state, + ): void { + [$directory, $manifest, $results] = $this->evidence($state); + + try { + $this->expectException(Exception::class); + $this->expectExceptionMessage($state); + + (new Verifier($manifest, $results))->verify(); + } finally { + $this->remove($directory); + } + } + + /** + * @return iterable + */ + public static function unsuccessfulStates(): iterable + { + yield 'error' => ['error']; + yield 'failed' => ['failed']; + yield 'skipped' => ['skipped']; + } + + /** + * @return array{string, string, string} + */ + private function evidence( + string $state = 'passed', + bool $executed = true, + ): array { + $directory = sys_get_temp_dir() + . '/docker-base-evidence-' + . bin2hex(random_bytes(8)); + if (! mkdir($directory)) { + self::fail( + "Unable to create temporary directory: {$directory}", + ); + } + $manifest = "{$directory}/manifest.json"; + $results = "{$directory}/results.xml"; + $contract = 'Example\\ContractTest::test_contract'; + $manifestContent = json_encode( + [ + 'baseline' => ['total' => 1], + 'contracts' => [['php' => $contract]], + ], + JSON_THROW_ON_ERROR, + ); + self::assertSame( + strlen($manifestContent), + file_put_contents($manifest, $manifestContent), + ); + + $case = ''; + if ($executed) { + $element = $state === 'failed' ? 'failure' : $state; + $failure = $state === 'passed' + ? '' + : "<{$element}/>"; + $case = '' + . $failure + . ''; + } + $resultsContent = '' + . "{$case}"; + self::assertSame( + strlen($resultsContent), + file_put_contents($results, $resultsContent), + ); + + return [$directory, $manifest, $results]; + } + + private function remove(string $directory): void + { + unlink("{$directory}/manifest.json"); + unlink("{$directory}/results.xml"); + rmdir($directory); + } +} diff --git a/.github/scripts/tests/equivalence.json b/.github/scripts/tests/equivalence.json new file mode 100644 index 0000000..df8d930 --- /dev/null +++ b/.github/scripts/tests/equivalence.json @@ -0,0 +1,651 @@ +{ + "baseline": { + "automation": 43, + "dependency": 35, + "adapter": 13, + "total": 91 + }, + "deterministic": { + "bytes": 5477, + "sha256": "50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77" + }, + "contracts": [ + { + "python": "test_automation.DeadlineTest.test_rejects_naive_times_and_nonpositive_timeouts", + "php": "DockerBase\\Tests\\Unit\\Automation\\DeadlineTest::test_rejects_naive_times_and_nonpositive_timeouts", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.DeadlineTest.test_uses_injected_time_and_clamps_remaining_time", + "php": "DockerBase\\Tests\\Unit\\Automation\\DeadlineTest::test_uses_injected_time_and_clamps_remaining_time", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.MergeResultTest.test_accepts_merge_for_exact_tested_head", + "php": "DockerBase\\Tests\\Unit\\Automation\\MergeResultTest::test_accepts_merge_for_exact_tested_head", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.MergeResultTest.test_rejects_a_non_squash_merge_commit", + "php": "DockerBase\\Tests\\Unit\\Automation\\MergeResultTest::test_rejects_a_non_squash_merge_commit", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.MergeResultTest.test_rejects_merge_commit_for_a_different_tested_base", + "php": "DockerBase\\Tests\\Unit\\Automation\\MergeResultTest::test_rejects_merge_commit_for_a_different_tested_base", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.MergeResultTest.test_rejects_merged_state_for_a_different_final_head", + "php": "DockerBase\\Tests\\Unit\\Automation\\MergeResultTest::test_rejects_merged_state_for_a_different_final_head", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.PullRequestTest.test_accepts_current_approved_head", + "php": "DockerBase\\Tests\\Unit\\Automation\\PullRequestTest::test_accepts_current_approved_head", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.PullRequestTest.test_rejects_changed_base_after_ci_succeeds", + "php": "DockerBase\\Tests\\Unit\\Automation\\PullRequestTest::test_rejects_changed_base_after_ci_succeeds", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.PullRequestTest.test_rejects_changed_head_even_if_currently_approved", + "php": "DockerBase\\Tests\\Unit\\Automation\\PullRequestTest::test_rejects_changed_head_even_if_currently_approved", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.PullRequestTest.test_rejects_missing_current_approval", + "php": "DockerBase\\Tests\\Unit\\Automation\\PullRequestTest::test_rejects_missing_current_approval", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_does_not_resume_merge_of_an_untested_base", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_does_not_resume_merge_of_an_untested_base", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_does_not_resume_wrong_target_draft", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_does_not_resume_wrong_target_draft", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_fails_closed_for_ambiguous_proven_untagged_merges", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_fails_closed_for_ambiguous_proven_untagged_merges", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_fails_closed_for_multiple_recoverable_releases", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_fails_closed_for_multiple_recoverable_releases", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_ignores_tag_for_unmarked_or_multi_file_pull_request", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_ignores_tag_for_unmarked_or_multi_file_pull_request", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_ignores_unrelated_orphan_tag", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_ignores_unrelated_orphan_tag", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_resumes_draft_after_publish_failure", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_resumes_draft_after_publish_failure", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_resumes_proven_merge_when_cancelled_before_tag_creation", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_resumes_proven_merge_when_cancelled_before_tag_creation", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.RecoveryTest.test_resumes_tag_when_draft_creation_failed_and_next_run_has_no_diff", + "php": "DockerBase\\Tests\\Unit\\Automation\\RecoveryTest::test_resumes_tag_when_draft_creation_failed_and_next_run_has_no_diff", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.TargetTest.test_accepts_exact_tag_and_release_targets", + "php": "DockerBase\\Tests\\Unit\\Automation\\TargetTest::test_accepts_exact_tag_and_release_targets", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.TargetTest.test_rejects_release_target_mismatch", + "php": "DockerBase\\Tests\\Unit\\Automation\\TargetTest::test_rejects_release_target_mismatch", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.TargetTest.test_rejects_tag_target_mismatch", + "php": "DockerBase\\Tests\\Unit\\Automation\\TargetTest::test_rejects_tag_target_mismatch", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_computes_new_patch_when_every_newer_tag_is_released", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_computes_new_patch_when_every_newer_tag_is_released", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_ignores_nonstable_and_prefixed_tags", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_ignores_nonstable_and_prefixed_tags", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_ignores_unreleased_holes_older_than_latest_release", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_ignores_unreleased_holes_older_than_latest_release", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_latest_version_requires_a_stable_remote_tag", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_latest_version_requires_a_stable_remote_tag", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_next_patch_uses_semantic_maximum_of_all_remote_tags", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_next_patch_uses_semantic_maximum_of_all_remote_tags", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_orders_stable_versions_semantically", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_orders_stable_versions_semantically", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_recomputes_after_collision_from_refreshed_remote_tags", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_recomputes_after_collision_from_refreshed_remote_tags", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_rejects_malformed_collision_tag", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_rejects_malformed_collision_tag", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.VersionTest.test_resumes_newest_tag_newer_than_latest_release", + "php": "DockerBase\\Tests\\Unit\\Automation\\VersionTest::test_resumes_newest_tag_newer_than_latest_release", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_does_not_accept_branch_run_for_tag_release", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_does_not_accept_branch_run_for_tag_release", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_preserves_terminal_failure_after_deadline", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_preserves_terminal_failure_after_deadline", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_reports_cancelled_run", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_reports_cancelled_run", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_reports_failed_run", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_reports_failed_run", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_reports_missing_run_before_deadline", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_reports_missing_run_before_deadline", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_reports_pending_run_before_deadline", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_reports_pending_run_before_deadline", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_reports_run_timeout_conclusion", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_reports_run_timeout_conclusion", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_reports_successful_run", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_reports_successful_run", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_selects_newest_run_then_newest_rerun_attempt", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_selects_newest_run_then_newest_rerun_attempt", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_selects_only_exact_runs_at_or_after_boundary", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_selects_only_exact_runs_at_or_after_boundary", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_times_out_missing_run_at_deadline", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_times_out_missing_run_at_deadline", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_automation.WorkflowTest.test_times_out_pending_run_at_deadline", + "php": "DockerBase\\Tests\\Unit\\Automation\\WorkflowTest::test_times_out_pending_run_at_deadline", + "group": "automation", + "fixture": "ported automation policy vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_dry_run_does_not_mutate_dockerfile_or_siblings", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_dry_run_does_not_mutate_dockerfile_or_siblings", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_injects_every_external_interaction", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_injects_every_external_interaction", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_noop_plan_preserves_content_exactly", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_noop_plan_preserves_content_exactly", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_plans_updates_without_touching_references", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_plans_updates_without_touching_references", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_reads_every_expected_declaration_once", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_reads_every_expected_declaration_once", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_real_dockerfile_declarations_match_independent_contract", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_real_dockerfile_declarations_match_independent_contract", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_duplicate_declaration", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_duplicate_declaration", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_invalid_base_digest", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_invalid_base_digest", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_invalid_or_ambiguous_digest_output", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_invalid_or_ambiguous_digest_output", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_invalid_pin_spelling", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_invalid_pin_spelling", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_missing_and_duplicate_base_declarations", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_missing_and_duplicate_base_declarations", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_missing_declaration", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_missing_declaration", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_non_dockerfile_target", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_non_dockerfile_target", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_rejects_unknown_extension_declaration", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_rejects_unknown_extension_declaration", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_resolves_lowercase_multiarch_digest_through_buildx", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_resolves_lowercase_multiarch_digest_through_buildx", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.DockerfileTests.test_update_mutates_only_dockerfile", + "php": "DockerBase\\Tests\\Unit\\Dependency\\DockerfileTest::test_update_mutates_only_dockerfile", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.ReleaseParsingTests.test_filters_pecl_by_stable_state_and_exact_version", + "php": "DockerBase\\Tests\\Unit\\Dependency\\ResolverTest::test_filters_pecl_by_stable_state_and_exact_version", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.ReleaseParsingTests.test_parses_only_exact_git_version_tags", + "php": "DockerBase\\Tests\\Unit\\Dependency\\ResolverTest::test_parses_only_exact_git_version_tags", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.ReleaseParsingTests.test_rejects_invalid_pecl_xml", + "php": "DockerBase\\Tests\\Unit\\Dependency\\ResolverTest::test_rejects_invalid_pecl_xml", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.ReleaseParsingTests.test_rejects_source_with_no_stable_releases", + "php": "DockerBase\\Tests\\Unit\\Dependency\\ResolverTest::test_rejects_source_with_no_stable_releases", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.ReportTests.test_renders_explicit_noop_report", + "php": "DockerBase\\Tests\\Unit\\Dependency\\ReporterTest::test_renders_explicit_noop_report", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.ReportTests.test_renders_markdown_update_report", + "php": "DockerBase\\Tests\\Unit\\Dependency\\ReporterTest::test_renders_markdown_update_report", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.SourceTests.test_defines_all_thirteen_extensions", + "php": "DockerBase\\Tests\\Unit\\Dependency\\CatalogTest::test_defines_all_thirteen_extensions", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.SourceTests.test_domain_classes_are_in_matching_files", + "php": "DockerBase\\Tests\\Unit\\Dependency\\CatalogTest::test_domain_classes_are_in_matching_files", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.SourceTests.test_source_definitions_are_immutable", + "php": "DockerBase\\Tests\\Unit\\Dependency\\CatalogTest::test_source_definitions_are_immutable", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.SourceTests.test_uses_exact_dockerfile_sources", + "php": "DockerBase\\Tests\\Unit\\Dependency\\CatalogTest::test_uses_exact_dockerfile_sources", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_ignores_higher_major", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_ignores_higher_major", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_ignores_prereleases", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_ignores_prereleases", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_never_downgrades", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_never_downgrades", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_parses_only_exact_stable_versions", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_parses_only_exact_stable_versions", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_prefers_current_prefix_for_equivalent_tags", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_prefers_current_prefix_for_equivalent_tags", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_preserves_selected_upstream_prefix", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_preserves_selected_upstream_prefix", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_rejects_invalid_current_version", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_rejects_invalid_current_version", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_selects_minor_and_patch_updates", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_selects_minor_and_patch_updates", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_dependencies.VersionTests.test_selects_semantic_maximum_not_lexical_maximum", + "php": "DockerBase\\Tests\\Unit\\Dependency\\VersionTest::test_selects_semantic_maximum_not_lexical_maximum", + "group": "dependency", + "fixture": "ported dependency discovery and Dockerfile vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_accepts_nonzero_merge_only_after_exact_remote_proof", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_accepts_nonzero_merge_only_after_exact_remote_proof", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_does_not_publish_when_prepublication_target_changed", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_does_not_publish_when_prepublication_target_changed", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_exact_lookup_finds_published_release_omitted_from_list", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_exact_lookup_finds_published_release_omitted_from_list", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_existing_exact_draft_avoids_duplicate_create", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_existing_exact_draft_avoids_duplicate_create", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_existing_published_release_prevents_duplicate_draft", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_existing_published_release_prevents_duplicate_draft", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_finds_draft_after_release_by_tag_returns_404", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_finds_draft_after_release_by_tag_returns_404", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_handles_missing_release_by_tag_as_none", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_handles_missing_release_by_tag_as_none", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_recovers_concurrently_created_draft_after_422", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_recovers_concurrently_created_draft_after_422", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_recovers_merge_cancelled_before_tag_on_next_no_diff_run", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_recovers_merge_cancelled_before_tag_on_next_no_diff_run", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_rejects_merge_when_squash_parent_is_not_tested_base", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_rejects_merge_when_squash_parent_is_not_tested_base", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_rejects_nonzero_merge_without_merged_remote_state", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_rejects_nonzero_merge_without_merged_remote_state", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_resolves_recovery_commit_when_rest_merge_sha_is_null", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_resolves_recovery_commit_when_rest_merge_sha_is_null", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + }, + { + "python": "test_orchestrator.OrchestratorTest.test_returns_release_to_draft_when_postpublication_target_changed", + "php": "DockerBase\\Tests\\Unit\\Automation\\OrchestratorTest::test_returns_release_to_draft_when_postpublication_target_changed", + "group": "adapter", + "fixture": "ported GitHub command queue vector", + "assertion": "same value, exception, bytes, mutation, and status contract" + } + ] +} diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 13ff034..8acb54e 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -2,8 +2,6 @@ name: Build and Push on: push: - release: - types: [published] permissions: contents: read diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 0000000..084e3a9 --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,256 @@ +name: Update Dependencies + +on: + schedule: + - cron: '0 3 * * 1' + workflow_dispatch: + +concurrency: + group: dependencies-${{ github.repository }} + cancel-in-progress: false + +permissions: + actions: read + checks: read + contents: read + pull-requests: write + +env: + GITHUB_API_VERSION: '2026-03-10' + +jobs: + update: + runs-on: ubuntu-24.04 + timeout-minutes: 300 + steps: + - name: Checkout repository + uses: actions/checkout@v6.1.0 + with: + token: ${{ github.token }} + persist-credentials: false + + - name: Preflight PHP runtime + run: | + # shellcheck disable=SC2016 + php -r ' + if (PHP_VERSION_ID < 80300) { + fwrite(STDERR, "PHP 8.3 or newer is required.\n"); + exit(1); + } + $required = [ + "dom", + "filter", + "json", + "libxml", + "mbstring", + "openssl", + "phar", + "tokenizer", + "xml", + "xmlwriter", + ]; + $missing = array_values(array_filter( + $required, + static fn (string $extension): bool => !extension_loaded($extension), + )); + if ($missing !== []) { + fwrite( + STDERR, + "Missing PHP extensions: " . implode(", ", $missing) . "\n", + ); + exit(1); + } + ' + + - name: Validate Composer configuration + run: composer validate --strict + + - name: Install Composer dependencies + run: composer install --no-interaction --no-progress + + - name: Check Composer platform requirements + run: composer check-platform-reqs + + - name: Verify PHP automation + run: composer verify + + - name: Recover an incomplete dependency release + id: recover + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: php .github/scripts/bin/orchestrator.php recover + + - name: Update dependencies + id: update + if: steps.recover.outputs.pending != 'true' + run: | + report="${RUNNER_TEMP}/dependencies.md" + php .github/scripts/bin/dependencies.php | tee "${report}" + cat "${report}" >> "${GITHUB_STEP_SUMMARY}" + + if git diff --quiet -- Dockerfile; then + echo 'changed=false' >> "${GITHUB_OUTPUT}" + exit 0 + fi + + mapfile -t files < <(git diff --name-only) + if (( ${#files[@]} != 1 )) || [[ "${files[0]}" != 'Dockerfile' ]]; then + echo 'The updater changed files other than Dockerfile.' >&2 + printf '%s\n' "${files[@]}" >&2 + exit 1 + fi + + git diff --check + branch="automation/dependencies-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + git switch --create "${branch}" + git config user.name 'github-actions[bot]' + git config user.email \ + '41898282+github-actions[bot]@users.noreply.github.com' + git add -- Dockerfile + git commit --message 'chore: update dependencies' + + { + echo 'changed=true' + printf 'branch=%s\n' "${branch}" + printf 'base=%s\n' "$(git rev-parse HEAD^)" + printf 'head=%s\n' "$(git rev-parse HEAD)" + } >> "${GITHUB_OUTPUT}" + + - name: Push update branch + id: push + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + branch='${{ steps.update.outputs.branch }}' + head='${{ steps.update.outputs.head }}' + created="$(date --utc +'%Y-%m-%dT%H:%M:%SZ')" + authorization="$( + printf 'x-access-token:%s' "${GH_TOKEN}" | base64 --wrap=0 + )" + echo "::add-mask::${authorization}" + + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0='http.https://github.com/.extraheader' \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${authorization}" \ + git push "https://github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${branch}" + unset authorization + + { + printf 'branch=%s\n' "${branch}" + printf 'head=%s\n' "${head}" + printf 'created=%s\n' "${created}" + } >> "${GITHUB_OUTPUT}" + + - name: Open pull request + id: pull + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + branch='${{ steps.push.outputs.branch }}' + head='${{ steps.push.outputs.head }}' + base='${{ steps.update.outputs.base }}' + body="${RUNNER_TEMP}/pull-request.md" + { + echo '' + printf '\n' "${head}" + printf '\n' "${base}" + echo 'Automated weekly dependency update.' + echo + cat "${RUNNER_TEMP}/dependencies.md" + } > "${body}" + + url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base main \ + --head "${branch}" \ + --title 'chore: update dependencies' \ + --body-file "${body}" + )" + pull="$( + gh pr view "${url}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json baseRefName,baseRefOid,createdAt,headRefName,headRefOid,number,state \ + --jq '.' + )" + printf '%s' "${pull}" | + php .github/scripts/bin/orchestrator.php validate-pull \ + "${branch}" "${head}" "${base}" + printf 'url=%s\n' "${url}" >> "${GITHUB_OUTPUT}" + + - name: Wait for exact CI runs + if: steps.update.outputs.changed == 'true' + env: + BRANCH: ${{ steps.push.outputs.branch }} + CREATED: ${{ steps.push.outputs.created }} + GH_TOKEN: ${{ github.token }} + HEAD: ${{ steps.push.outputs.head }} + run: | + php .github/scripts/bin/orchestrator.php wait-checks \ + "${BRANCH}" "${HEAD}" "${CREATED}" + + - name: Approve pull request + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api \ + -X POST \ + -H "X-GitHub-Api-Version: ${GITHUB_API_VERSION}" \ + "repos/${GITHUB_REPOSITORY}/pulls/${{ steps.pull.outputs.number }}/reviews" \ + -f event=APPROVE \ + -f commit_id='${{ steps.push.outputs.head }}' \ + -f body='Automated dependency checks passed.' \ + --silent + + - name: Validate and merge pull request + id: merge + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + php .github/scripts/bin/orchestrator.php merge \ + '${{ steps.pull.outputs.number }}' \ + '${{ steps.push.outputs.head }}' \ + '${{ steps.pull.outputs.base }}' + + - name: Prepare exact tag and draft release + id: release + if: >- + steps.update.outputs.changed == 'true' || + steps.recover.outputs.pending == 'true' + env: + DRAFT: ${{ steps.recover.outputs.draft }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + HEAD: >- + ${{ steps.merge.outputs.head || steps.recover.outputs.head }} + PULL: >- + ${{ steps.pull.outputs.number || steps.recover.outputs.pull }} + TAG: ${{ steps.recover.outputs.tag }} + run: | + php .github/scripts/bin/orchestrator.php prepare \ + "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" + + - name: Wait for exact tag Build and Push run + if: steps.release.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + HEAD: ${{ steps.release.outputs.head }} + TAG: ${{ steps.release.outputs.tag }} + run: | + php .github/scripts/bin/orchestrator.php wait "${TAG}" "${HEAD}" + + - name: Verify and publish exact release + if: steps.release.outcome == 'success' + env: + DRAFT: ${{ steps.release.outputs.draft }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + HEAD: ${{ steps.release.outputs.head }} + PULL: ${{ steps.release.outputs.pull }} + TAG: ${{ steps.release.outputs.tag }} + run: | + php .github/scripts/bin/orchestrator.php publish \ + "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" diff --git a/.gitignore b/.gitignore index 485dee6..f76a5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .idea +/.phpstan-cache/ +/.phpunit.cache/ +/.phpunit.results.xml +/vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6fb5634 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "appwrite/docker-base", + "description": "Tooling for Appwrite's shared PHP Docker base image", + "type": "project", + "license": "MIT", + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-phar": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "php": ">=8.3", + "utopia-php/cli": "0.*", + "utopia-php/console": "0.2.*" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "12.5.*" + }, + "autoload": { + "psr-4": { + "DockerBase\\": ".github/scripts/src/" + } + }, + "autoload-dev": { + "psr-4": { + "DockerBase\\Tests\\": ".github/scripts/tests/" + } + }, + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.3.0" + }, + "sort-packages": true + }, + "scripts": { + "lint": "vendor/bin/pint --test .github/scripts", + "format": "vendor/bin/pint .github/scripts", + "check": "vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G", + "test": "vendor/bin/phpunit --configuration=phpunit.xml --log-junit=.phpunit.results.xml", + "parity": "php .github/scripts/bin/parity.php .phpunit.results.xml", + "verify": [ + "@lint", + "@check", + "@test", + "@parity" + ] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..93ad1f0 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2127 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "f96ccefbb0769dfa4d27577991641c1d", + "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "utopia-php/cli", + "version": "0.24.3", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/cli.git", + "reference": "d6a27f2902e777c2985e834ffb3e662130fd7ecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/cli/zipball/d6a27f2902e777c2985e834ffb3e662130fd7ecc", + "reference": "d6a27f2902e777c2985e834ffb3e662130fd7ecc", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "utopia-php/servers": "^0.4" + }, + "require-dev": { + "swoole/ide-helper": "4.8.8", + "utopia-php/console": "0.0.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\CLI\\": "src/CLI" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple CLI library to manage command line applications", + "keywords": [ + "cli", + "command line", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/cli/issues", + "source": "https://github.com/utopia-php/cli/tree/0.24.3" + }, + "time": "2026-06-26T10:48:18+00:00" + }, + { + "name": "utopia-php/console", + "version": "0.2.3", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/console.git", + "reference": "e67316f7872b6f7b2c16b246fb696d3059d108bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/console/zipball/e67316f7872b6f7b2c16b246fb696d3059d108bd", + "reference": "e67316f7872b6f7b2c16b246fb696d3059d108bd", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/validators": "^0.2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Console helpers for logging, prompting, and executing commands", + "keywords": [ + "cli", + "console", + "php", + "terminal", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/console/issues", + "source": "https://github.com/utopia-php/console/tree/0.2.3" + }, + "time": "2026-06-26T10:48:18+00:00" + }, + { + "name": "utopia-php/di", + "version": "0.3.5", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/di.git", + "reference": "053ebd097963190c5b279d66c5b530acb584d8c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/di/zipball/053ebd097963190c5b279d66c5b530acb584d8c0", + "reference": "053ebd097963190c5b279d66c5b530acb584d8c0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^2.0" + }, + "require-dev": { + "phpbench/phpbench": "^1.2", + "swoole/ide-helper": "4.8.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/", + "Tests\\E2E\\": "tests/e2e" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple and lite library for managing dependency injections", + "keywords": [ + "PSR-11", + "container", + "dependency-injection", + "di", + "php", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/di/issues", + "source": "https://github.com/utopia-php/di/tree/0.3.5" + }, + "time": "2026-06-26T10:48:18+00:00" + }, + { + "name": "utopia-php/servers", + "version": "0.4.5", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/servers.git", + "reference": "34067d96124dcb8558bdc5169f1ca01214fc3f54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/servers/zipball/34067d96124dcb8558bdc5169f1ca01214fc3f54", + "reference": "34067d96124dcb8558bdc5169f1ca01214fc3f54", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "utopia-php/di": "^0.3", + "utopia-php/validators": "^0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Servers\\": "src/Servers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A base library for building Utopia style servers.", + "keywords": [ + "framework", + "php", + "servers", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/servers/issues", + "source": "https://github.com/utopia-php/servers/tree/0.4.5" + }, + "time": "2026-06-26T10:48:18+00:00" + }, + { + "name": "utopia-php/validators", + "version": "0.2.8", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/53c0cad30b4212627f5bce727af7b97d6ee05b5f", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.8" + }, + "time": "2026-06-26T10:48:18+00:00" + } + ], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.29.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.8", + "illuminate/view": "^12.62.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-06-16T15:34:04+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-05T06:31:06+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.31", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0608d157a284f15cc73b99a3327eff06b66a176d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d", + "reference": "0608d157a284f15cc73b99a3327eff06b66a176d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:54:16+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-phar": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "php": ">=8.3" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f844408 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + phpVersion: 80300 + paths: + - .github/scripts/src + - .github/scripts/tests + tmpDir: .phpstan-cache diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..eb23894 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + .github/scripts/tests/Unit + + + .github/scripts/tests/E2E + + + + + .github/scripts/src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..cd221eb --- /dev/null +++ b/pint.json @@ -0,0 +1,16 @@ +{ + "preset": "psr12", + "rules": { + "array_indentation": true, + "ordered_imports": { + "imports_order": [ + "const", + "class", + "function" + ], + "sort_algorithm": "alpha" + }, + "simplified_null_return": true, + "single_import_per_statement": true + } +}