From 5b51b84ba8741fa30197eb4a1ef391947348906c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 15:04:07 +1200 Subject: [PATCH 01/19] feat: add weekly dependency updater --- .github/scripts/dependencies.py | 514 ++++++++++++++++++++++ .github/scripts/test_dependencies.py | 632 +++++++++++++++++++++++++++ 2 files changed, 1146 insertions(+) create mode 100644 .github/scripts/dependencies.py create mode 100644 .github/scripts/test_dependencies.py diff --git a/.github/scripts/dependencies.py b/.github/scripts/dependencies.py new file mode 100644 index 0000000..f462516 --- /dev/null +++ b/.github/scripts/dependencies.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +"""Update the Dockerfile's pinned base image and PHP extension releases.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import urllib.request +import xml.etree.ElementTree as ElementTree +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path + + +BASE_NAME = 'php:8.5-alpine' +BASE_PATTERN = re.compile( + rf'{re.escape(BASE_NAME)}@(sha256:[0-9a-f]{{64}})' +) +DIGEST_PATTERN = re.compile(r'sha256:[0-9a-f]{64}') +VERSION_PATTERN = re.compile(r'v?([0-9]+)\.([0-9]+)\.([0-9]+)') +PECL_RELEASES = 'https://pecl.php.net/rest/r/protobuf/allreleases.xml' + +CommandRunner = Callable[[tuple[str, ...]], str] +Fetcher = Callable[[str], bytes] + + +class UpdateError(RuntimeError): + """Raised when dependency discovery or Dockerfile validation fails.""" + + +@dataclass(frozen=True, slots=True) +class GitSource: + """A git repository whose exact version tags are release candidates.""" + + url: str + + +@dataclass(frozen=True, slots=True) +class PeclSource: + """A PECL stable-release feed.""" + + url: str + + +Source = GitSource | PeclSource + + +@dataclass(frozen=True, order=True, slots=True) +class Version: + """A stable semantic release.""" + + major: int + minor: int + patch: int + + +@dataclass(frozen=True, slots=True) +class Dependency: + """A Dockerfile variable and its authoritative release source.""" + + name: str + variable: str + source: Source + + +@dataclass(frozen=True, slots=True) +class Pin: + """An exact declaration location in the Dockerfile.""" + + name: str + current: str + start: int + end: int + + +@dataclass(frozen=True, slots=True) +class Change: + """The current and selected value for one dependency.""" + + name: str + current: str + latest: str + + @property + def changed(self) -> bool: + return self.current != self.latest + + +@dataclass(frozen=True, slots=True) +class Plan: + """The complete in-memory Dockerfile update.""" + + content: str + changes: tuple[Change, ...] + + @property + def changed(self) -> bool: + return any(change.changed for change in self.changes) + + +DEPENDENCIES: tuple[Dependency, ...] = ( + Dependency( + 'brotli', + 'PHP_BROTLI_VERSION', + GitSource('https://github.com/kjdev/php-ext-brotli.git'), + ), + Dependency( + 'imagick', + 'PHP_IMAGICK_VERSION', + GitSource('https://github.com/imagick/imagick'), + ), + Dependency( + 'lz4', + 'PHP_LZ4_VERSION', + GitSource('https://github.com/kjdev/php-ext-lz4.git'), + ), + Dependency( + 'maxminddb', + 'PHP_MAXMINDDB_VERSION', + GitSource( + 'https://github.com/maxmind/MaxMind-DB-Reader-php.git' + ), + ), + Dependency( + 'mongodb', + 'PHP_MONGODB_VERSION', + GitSource('https://github.com/mongodb/mongo-php-driver.git'), + ), + Dependency( + 'protobuf', + 'PHP_PROTOBUF_VERSION', + PeclSource(PECL_RELEASES), + ), + Dependency( + 'redis', + 'PHP_REDIS_VERSION', + GitSource('https://github.com/phpredis/phpredis.git'), + ), + Dependency( + 'scrypt', + 'PHP_SCRYPT_VERSION', + GitSource('https://github.com/DomBlack/php-scrypt.git'), + ), + Dependency( + 'snappy', + 'PHP_SNAPPY_VERSION', + GitSource('https://github.com/kjdev/php-ext-snappy.git'), + ), + Dependency( + 'swoole', + 'PHP_SWOOLE_VERSION', + GitSource('https://github.com/swoole/swoole-src.git'), + ), + Dependency( + 'xdebug', + 'PHP_XDEBUG_VERSION', + GitSource('https://github.com/xdebug/xdebug'), + ), + Dependency( + 'yaml', + 'PHP_YAML_VERSION', + GitSource('https://github.com/php/pecl-file_formats-yaml'), + ), + Dependency( + 'zstd', + 'PHP_ZSTD_VERSION', + GitSource('https://github.com/kjdev/php-ext-zstd.git'), + ), +) + + +def parse_version(spelling: str) -> Version | None: + """Parse only an exact stable v?MAJOR.MINOR.PATCH spelling.""" + + match = VERSION_PATTERN.fullmatch(spelling) + if match is None: + return None + return Version(*(int(part) for part in match.groups())) + + +def select_version(current: str, releases: Iterable[str]) -> str: + """Select the newest non-downgrading release in the current major.""" + + current_version = parse_version(current) + if current_version is None: + raise UpdateError(f'Invalid current version: {current}') + + parsed = tuple( + (version, release) + for release in releases + if (version := parse_version(release)) is not None + and version.major == current_version.major + and version > current_version + ) + if not parsed: + return current + + latest_version = max(version for version, _ in parsed) + spellings = tuple( + release + for version, release in parsed + if version == latest_version + ) + current_has_prefix = current.startswith('v') + matching = tuple( + spelling + for spelling in spellings + if spelling.startswith('v') == current_has_prefix + ) + return min(matching or spellings) + + +def parse_git_tags(output: str) -> tuple[str, ...]: + """Extract exact tag names from git ls-remote output.""" + + prefix = 'refs/tags/' + tags: list[str] = [] + for line in output.splitlines(): + fields = line.split() + if len(fields) != 2 or not fields[1].startswith(prefix): + continue + tag = fields[1][len(prefix) :] + if parse_version(tag) is not None: + tags.append(tag) + return tuple(tags) + + +def parse_pecl_releases(document: bytes) -> tuple[str, ...]: + """Extract exact stable releases from a PECL allreleases document.""" + + try: + root = ElementTree.fromstring(document) + except ElementTree.ParseError as error: + raise UpdateError(f'Invalid PECL release XML: {error}') from error + + releases: list[str] = [] + for release in root: + if _local_name(release.tag) != 'r': + continue + fields = { + _local_name(child.tag): (child.text or '').strip() + for child in release + } + spelling = fields.get('v', '') + if ( + fields.get('s', '').lower() == 'stable' + and parse_version(spelling) is not None + ): + releases.append(spelling) + return tuple(releases) + + +def _local_name(name: str) -> str: + return name.rsplit('}', 1)[-1] + + +def run_command(command: tuple[str, ...]) -> str: + """Run one discovery command and return stdout.""" + + try: + completed = subprocess.run( + command, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as error: + detail = getattr(error, 'stderr', '') or str(error) + raise UpdateError( + f'Command failed: {" ".join(command)}: {detail.strip()}' + ) from error + return completed.stdout + + +def fetch(url: str) -> bytes: + """Fetch one authoritative release feed.""" + + try: + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + except OSError as error: + raise UpdateError(f'Failed to fetch {url}: {error}') from error + + +def resolve_digest(runner: CommandRunner = run_command) -> str: + """Resolve and validate the base image's multi-architecture digest.""" + + output = runner( + ('docker', 'buildx', 'imagetools', 'inspect', BASE_NAME) + ) + matches = tuple( + match.group(1) + for match in re.finditer( + r'(?m)^Digest:[ \t]*(sha256:[0-9a-f]{64})[ \t]*$', + output, + ) + ) + if len(matches) != 1 or DIGEST_PATTERN.fullmatch(matches[0]) is None: + raise UpdateError( + f'Expected one lowercase sha256 digest for {BASE_NAME}' + ) + return matches[0] + + +def discover_releases( + dependency: Dependency, + runner: CommandRunner = run_command, + fetcher: Fetcher = fetch, +) -> tuple[str, ...]: + """Read stable release spellings from the configured typed source.""" + + if isinstance(dependency.source, GitSource): + output = runner( + ( + 'git', + 'ls-remote', + '--tags', + '--refs', + dependency.source.url, + ) + ) + releases = parse_git_tags(output) + else: + releases = parse_pecl_releases(fetcher(dependency.source.url)) + + if not releases: + raise UpdateError( + f'No exact stable releases found for {dependency.name}' + ) + return releases + + +def read_pins(content: str) -> tuple[Pin, ...]: + """Validate and locate every expected Dockerfile declaration once.""" + + image_expression = re.compile( + r'(?m)^[ \t]*ARG[ \t]+BASE_IMAGE="([^"\r\n]+)"[ \t]*$' + ) + image_match = _single_match( + image_expression, + content, + 'ARG BASE_IMAGE', + ) + image_value = image_match.group(1) + value_match = BASE_PATTERN.fullmatch(image_value) + if value_match is None: + raise UpdateError( + f'ARG BASE_IMAGE must pin {BASE_NAME} to a lowercase sha256 digest' + ) + digest_start = image_match.start(1) + value_match.start(1) + pins: list[Pin] = [ + Pin( + BASE_NAME, + value_match.group(1), + digest_start, + digest_start + len(value_match.group(1)), + ) + ] + + for dependency in DEPENDENCIES: + expression = re.compile( + rf'(?m)^[ \t]*(?:ENV[ \t]+)?' + rf'{re.escape(dependency.variable)}=' + r'"([^"\r\n]+)"[ \t]*(?:\\)?[ \t]*$' + ) + match = _single_match( + expression, + content, + dependency.variable, + ) + current = match.group(1) + if parse_version(current) is None: + raise UpdateError( + f'{dependency.variable} must be an exact stable ' + 'v?MAJOR.MINOR.PATCH version' + ) + pins.append( + Pin( + dependency.name, + current, + match.start(1), + match.end(1), + ) + ) + + return tuple(pins) + + +def _single_match( + expression: re.Pattern[str], + content: str, + declaration: str, +) -> re.Match[str]: + matches = tuple(expression.finditer(content)) + if len(matches) != 1: + raise UpdateError( + f'Expected exactly one {declaration} declaration, ' + f'found {len(matches)}' + ) + return matches[0] + + +def plan_update( + content: str, + runner: CommandRunner = run_command, + fetcher: Fetcher = fetch, +) -> Plan: + """Resolve all releases before constructing an in-memory update.""" + + pins = read_pins(content) + latest = [resolve_digest(runner)] + for dependency, pin in zip(DEPENDENCIES, pins[1:], strict=True): + releases = discover_releases(dependency, runner, fetcher) + latest.append(select_version(pin.current, releases)) + + changes = tuple( + Change(pin.name, pin.current, selected) + for pin, selected in zip(pins, latest, strict=True) + ) + updated = content + for pin, selected in reversed( + tuple(zip(pins, latest, strict=True)) + ): + updated = updated[: pin.start] + selected + updated[pin.end :] + return Plan(updated, changes) + + +def render_report(plan: Plan) -> str: + """Render the complete update result as Markdown.""" + + changed = sum(change.changed for change in plan.changes) + rows = [ + '## Dependency update report', + '', + '| Dependency | Current | Selected | Result |', + '| --- | --- | --- | --- |', + ] + rows.extend( + '| {name} | `{current}` | `{latest}` | {result} |'.format( + name=change.name, + current=change.current, + latest=change.latest, + result='Updated' if change.changed else 'Current', + ) + for change in plan.changes + ) + rows.extend( + ( + '', + f'**Updates:** {changed}', + '', + ( + 'Dockerfile pins were updated.' + if changed + else 'No dependency updates were found.' + ), + ) + ) + return '\n'.join(rows) + + +def update( + dockerfile: Path, + *, + dry_run: bool = False, + runner: CommandRunner = run_command, + fetcher: Fetcher = fetch, +) -> Plan: + """Plan updates and optionally mutate only the requested Dockerfile.""" + + if dockerfile.name != 'Dockerfile': + raise UpdateError('The update target must be named Dockerfile') + content = dockerfile.read_text(encoding='utf-8') + plan = plan_update(content, runner, fetcher) + if plan.changed and not dry_run: + dockerfile.write_text(plan.content, encoding='utf-8') + return plan + + +def main(arguments: Sequence[str] | None = None) -> int: + """Run the dependency updater.""" + + root = Path(__file__).resolve().parents[2] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--dockerfile', + type=Path, + default=root / 'Dockerfile', + help='Dockerfile to inspect and update', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='report updates without modifying the Dockerfile', + ) + options = parser.parse_args(arguments) + + try: + plan = update( + options.dockerfile, + dry_run=options.dry_run, + ) + except (OSError, UpdateError) as error: + print(f'Error: {error}', file=sys.stderr) + return 1 + print(render_report(plan)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/test_dependencies.py b/.github/scripts/test_dependencies.py new file mode 100644 index 0000000..f195308 --- /dev/null +++ b/.github/scripts/test_dependencies.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +"""Offline tests for the Dockerfile dependency updater.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from dataclasses import FrozenInstanceError +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import dependencies + + +CURRENT: dict[str, str] = { + 'brotli': '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', +} +OLD_DIGEST = 'sha256:' + ('1' * 64) +NEW_DIGEST = 'sha256:' + ('2' * 64) + + +def dockerfile() -> str: + """Build a representative Dockerfile containing every required pin.""" + + lines = [ + f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{OLD_DIGEST}"', + '', + 'FROM $BASE_IMAGE AS compile', + '', + 'ENV \\', + ] + regular = tuple( + dependency + for dependency in dependencies.DEPENDENCIES + if dependency.name != 'xdebug' + ) + for index, dependency in enumerate(regular): + suffix = ' \\' if index < len(regular) - 1 else '' + lines.append( + f' {dependency.variable}="{CURRENT[dependency.name]}"' + f'{suffix}' + ) + lines.extend( + ( + '', + '# References should never be rewritten:', + 'RUN echo "$PHP_REDIS_VERSION"', + '', + 'FROM compile AS xdebug-build', + '', + f'ENV PHP_XDEBUG_VERSION="{CURRENT["xdebug"]}"', + '', + ) + ) + return '\n'.join(lines) + + +def git_tags(*spellings: str) -> str: + """Build offline git ls-remote output.""" + + return ''.join( + f'{"a" * 40}\trefs/tags/{spelling}\n' + for spelling in spellings + ) + + +def pecl_releases(*releases: tuple[str, str]) -> bytes: + """Build an offline namespaced PECL allreleases response.""" + + entries = ''.join( + f'{version}{state}' + for version, state in releases + ) + return ( + '' + '' + f'

protobuf

{entries}
' + ).encode() + + +class Discovery: + """Injectable, offline replacement for every command and network read.""" + + def __init__( + self, + *, + digest: str = OLD_DIGEST, + releases: dict[str, tuple[str, ...]] | None = None, + pecl: tuple[tuple[str, str], ...] | None = None, + ) -> None: + self.digest = digest + self.releases = releases or {} + self.pecl = pecl or ((CURRENT['protobuf'], 'stable'),) + self.commands: list[tuple[str, ...]] = [] + self.urls: list[str] = [] + + def run(self, command: tuple[str, ...]) -> str: + """Return deterministic output for docker buildx or git.""" + + self.commands.append(command) + if command == ( + 'docker', + 'buildx', + 'imagetools', + 'inspect', + dependencies.BASE_NAME, + ): + return ( + f'Name: docker.io/library/{dependencies.BASE_NAME}\n' + f'Digest: {self.digest}\n' + ) + if command[:4] == ('git', 'ls-remote', '--tags', '--refs'): + url = command[4] + dependency = next( + item + for item in dependencies.DEPENDENCIES + if isinstance(item.source, dependencies.GitSource) + and item.source.url == url + ) + spellings = self.releases.get( + dependency.name, + (CURRENT[dependency.name],), + ) + return git_tags(*spellings) + raise AssertionError(f'Unexpected command: {command}') + + def fetch(self, url: str) -> bytes: + """Return a deterministic PECL response.""" + + self.urls.append(url) + if url != dependencies.PECL_RELEASES: + raise AssertionError(f'Unexpected URL: {url}') + return pecl_releases(*self.pecl) + + +class SourceTests(unittest.TestCase): + """Verify the immutable source contract against Dockerfile clone URLs.""" + + def test_defines_all_thirteen_extensions(self) -> None: + self.assertEqual(13, len(dependencies.DEPENDENCIES)) + self.assertEqual( + set(CURRENT), + { + dependency.name + for dependency in dependencies.DEPENDENCIES + }, + ) + + def test_uses_exact_dockerfile_sources(self) -> None: + 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 = { + dependency.name: dependency.source.url + for dependency in dependencies.DEPENDENCIES + if isinstance(dependency.source, dependencies.GitSource) + } + self.assertEqual(expected, actual) + protobuf = next( + dependency + for dependency in dependencies.DEPENDENCIES + if dependency.name == 'protobuf' + ) + self.assertIsInstance(protobuf.source, dependencies.PeclSource) + self.assertEqual(dependencies.PECL_RELEASES, protobuf.source.url) + + def test_source_definitions_are_immutable(self) -> None: + source = dependencies.GitSource('https://example.test/repository') + with self.assertRaises(FrozenInstanceError): + source.url = 'https://example.test/changed' + + +class VersionTests(unittest.TestCase): + """Verify stable semantic same-major selection.""" + + def test_parses_only_exact_stable_versions(self) -> None: + self.assertEqual( + dependencies.Version(1, 2, 3), + dependencies.parse_version('1.2.3'), + ) + self.assertEqual( + dependencies.Version(1, 2, 3), + dependencies.parse_version('v1.2.3'), + ) + for spelling in ( + 'V1.2.3', + '1.2', + '1.2.3.4', + 'release-1.2.3', + '1.2.3RC1', + 'v1.2.3-beta.1', + ): + with self.subTest(spelling=spelling): + self.assertIsNone(dependencies.parse_version(spelling)) + + def test_selects_semantic_maximum_not_lexical_maximum(self) -> None: + self.assertEqual( + '1.10.0', + dependencies.select_version( + '1.2.0', + ('1.2.9', '1.10.0', '1.9.12'), + ), + ) + + def test_selects_minor_and_patch_updates(self) -> None: + self.assertEqual( + '1.3.0', + dependencies.select_version( + '1.2.3', + ('1.2.4', '1.3.0'), + ), + ) + self.assertEqual( + '1.2.4', + dependencies.select_version('1.2.3', ('1.2.4',)), + ) + + def test_ignores_higher_major(self) -> None: + self.assertEqual( + '1.2.3', + dependencies.select_version( + '1.2.3', + ('2.0.0', 'v3.4.5'), + ), + ) + + def test_ignores_prereleases(self) -> None: + self.assertEqual( + '1.2.4', + dependencies.select_version( + '1.2.3', + ('1.2.4RC1', 'v1.3.0-beta.1', '1.2.4'), + ), + ) + + def test_never_downgrades(self) -> None: + self.assertEqual( + '1.5.0', + dependencies.select_version( + '1.5.0', + ('1.4.9', '1.5.0', '2.0.0'), + ), + ) + + def test_preserves_selected_upstream_prefix(self) -> None: + self.assertEqual( + 'v1.3.0', + dependencies.select_version('1.2.3', ('v1.3.0',)), + ) + self.assertEqual( + '1.3.0', + dependencies.select_version('v1.2.3', ('1.3.0',)), + ) + + def test_prefers_current_prefix_for_equivalent_tags(self) -> None: + releases = ('1.3.0', 'v1.3.0') + self.assertEqual( + 'v1.3.0', + dependencies.select_version('v1.2.3', releases), + ) + self.assertEqual( + '1.3.0', + dependencies.select_version('1.2.3', releases), + ) + + def test_rejects_invalid_current_version(self) -> None: + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Invalid current version', + ): + dependencies.select_version('1.2.3RC1', ('1.2.4',)) + + +class ReleaseParsingTests(unittest.TestCase): + """Verify exact git tags and PECL stable-state handling.""" + + def test_parses_only_exact_git_version_tags(self) -> None: + output = ( + git_tags('1.2.3', 'v1.3.0', '1.4.0RC1', 'release-1.5.0') + + f'{"b" * 40}\trefs/heads/1.6.0\n' + + 'malformed\n' + ) + self.assertEqual( + ('1.2.3', 'v1.3.0'), + dependencies.parse_git_tags(output), + ) + + def test_filters_pecl_by_stable_state_and_exact_version(self) -> None: + document = pecl_releases( + ('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.assertEqual( + ('5.35.0', '5.34.2', 'v5.35.1'), + dependencies.parse_pecl_releases(document), + ) + + def test_rejects_invalid_pecl_xml(self) -> None: + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Invalid PECL release XML', + ): + dependencies.parse_pecl_releases(b'') + + def test_rejects_source_with_no_stable_releases(self) -> None: + protobuf = next( + dependency + for dependency in dependencies.DEPENDENCIES + if dependency.name == 'protobuf' + ) + discovery = Discovery(pecl=(('5.35.0RC1', 'beta'),)) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'No exact stable releases found for protobuf', + ): + dependencies.discover_releases( + protobuf, + discovery.run, + discovery.fetch, + ) + + +class DockerfileTests(unittest.TestCase): + """Verify declaration validation, digest resolution, and updates.""" + + def test_reads_every_expected_declaration_once(self) -> None: + pins = dependencies.read_pins(dockerfile()) + self.assertEqual(14, len(pins)) + self.assertEqual(dependencies.BASE_NAME, pins[0].name) + self.assertEqual(OLD_DIGEST, pins[0].current) + self.assertEqual(set(CURRENT), {pin.name for pin in pins[1:]}) + + def test_rejects_missing_declaration(self) -> None: + content = dockerfile().replace( + f' PHP_REDIS_VERSION="{CURRENT["redis"]}" \\\n', + '', + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Expected exactly one PHP_REDIS_VERSION declaration, found 0', + ): + dependencies.read_pins(content) + + def test_rejects_duplicate_declaration(self) -> None: + content = ( + dockerfile() + + f'\nENV PHP_REDIS_VERSION="{CURRENT["redis"]}"\n' + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Expected exactly one PHP_REDIS_VERSION declaration, found 2', + ): + dependencies.read_pins(content) + + def test_rejects_missing_and_duplicate_base_declarations(self) -> None: + missing = dockerfile().replace( + f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{OLD_DIGEST}"\n', + '', + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Expected exactly one ARG BASE_IMAGE declaration, found 0', + ): + dependencies.read_pins(missing) + + duplicate = ( + dockerfile() + + f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{OLD_DIGEST}"\n' + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Expected exactly one ARG BASE_IMAGE declaration, found 2', + ): + dependencies.read_pins(duplicate) + + def test_rejects_invalid_pin_spelling(self) -> None: + content = dockerfile().replace( + f'PHP_YAML_VERSION="{CURRENT["yaml"]}"', + 'PHP_YAML_VERSION="2.4.0RC1"', + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'PHP_YAML_VERSION must be an exact stable', + ): + dependencies.read_pins(content) + + def test_rejects_invalid_base_digest(self) -> None: + content = dockerfile().replace(OLD_DIGEST, 'sha256:ABC') + with self.assertRaisesRegex( + dependencies.UpdateError, + 'must pin php:8.5-alpine to a lowercase sha256 digest', + ): + dependencies.read_pins(content) + + def test_resolves_lowercase_multiarch_digest_through_buildx(self) -> None: + discovery = Discovery(digest=NEW_DIGEST) + self.assertEqual( + NEW_DIGEST, + dependencies.resolve_digest(discovery.run), + ) + self.assertEqual( + [ + ( + 'docker', + 'buildx', + 'imagetools', + 'inspect', + dependencies.BASE_NAME, + ) + ], + discovery.commands, + ) + + def test_rejects_invalid_or_ambiguous_digest_output(self) -> None: + invalid = lambda command: 'Digest: sha256:' + ('A' * 64) + '\n' + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Expected one lowercase sha256 digest', + ): + dependencies.resolve_digest(invalid) + + ambiguous = lambda command: ( + f'Digest: {OLD_DIGEST}\nDigest: {NEW_DIGEST}\n' + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Expected one lowercase sha256 digest', + ): + dependencies.resolve_digest(ambiguous) + + def test_plans_updates_without_touching_references(self) -> None: + discovery = Discovery( + digest=NEW_DIGEST, + releases={ + 'redis': (CURRENT['redis'], '6.3.1', 'v6.3.1', '7.0.0'), + 'swoole': (CURRENT['swoole'], '6.2.1', 'v6.2.1'), + }, + pecl=( + (CURRENT['protobuf'], 'stable'), + ('5.34.1', 'stable'), + ('5.35.0RC1', 'beta'), + ('6.0.0', 'stable'), + ), + ) + plan = dependencies.plan_update( + dockerfile(), + discovery.run, + discovery.fetch, + ) + self.assertTrue(plan.changed) + self.assertIn( + f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{NEW_DIGEST}"', + plan.content, + ) + self.assertIn('PHP_REDIS_VERSION="6.3.1"', plan.content) + self.assertIn('PHP_PROTOBUF_VERSION="5.34.1"', plan.content) + self.assertIn('PHP_SWOOLE_VERSION="v6.2.1"', plan.content) + self.assertIn('RUN echo "$PHP_REDIS_VERSION"', plan.content) + + def test_noop_plan_preserves_content_exactly(self) -> None: + content = dockerfile() + discovery = Discovery() + plan = dependencies.plan_update( + content, + discovery.run, + discovery.fetch, + ) + self.assertFalse(plan.changed) + self.assertEqual(content, plan.content) + + def test_injects_every_external_interaction(self) -> None: + discovery = Discovery() + dependencies.plan_update( + dockerfile(), + discovery.run, + discovery.fetch, + ) + git_sources = sum( + isinstance(dependency.source, dependencies.GitSource) + for dependency in dependencies.DEPENDENCIES + ) + self.assertEqual(1 + git_sources, len(discovery.commands)) + self.assertEqual([dependencies.PECL_RELEASES], discovery.urls) + for dependency in dependencies.DEPENDENCIES: + if not isinstance(dependency.source, dependencies.GitSource): + continue + self.assertIn( + ( + 'git', + 'ls-remote', + '--tags', + '--refs', + dependency.source.url, + ), + discovery.commands, + ) + + def test_dry_run_does_not_mutate_dockerfile_or_siblings(self) -> None: + discovery = Discovery( + digest=NEW_DIGEST, + releases={'redis': (CURRENT['redis'], '6.3.1')}, + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / 'Dockerfile' + sibling = root / 'keep.txt' + original = dockerfile() + path.write_text(original, encoding='utf-8') + sibling.write_text('unchanged', encoding='utf-8') + + plan = dependencies.update( + path, + dry_run=True, + runner=discovery.run, + fetcher=discovery.fetch, + ) + + self.assertTrue(plan.changed) + self.assertEqual(original, path.read_text(encoding='utf-8')) + self.assertEqual( + 'unchanged', + sibling.read_text(encoding='utf-8'), + ) + + def test_update_mutates_only_dockerfile(self) -> None: + discovery = Discovery(digest=NEW_DIGEST) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / 'Dockerfile' + sibling = root / 'keep.txt' + path.write_text(dockerfile(), encoding='utf-8') + sibling.write_text('unchanged', encoding='utf-8') + + plan = dependencies.update( + path, + runner=discovery.run, + fetcher=discovery.fetch, + ) + + self.assertEqual(plan.content, path.read_text(encoding='utf-8')) + self.assertEqual( + 'unchanged', + sibling.read_text(encoding='utf-8'), + ) + + def test_rejects_non_dockerfile_target(self) -> None: + with self.assertRaisesRegex( + dependencies.UpdateError, + 'target must be named Dockerfile', + ): + dependencies.update(Path('/tmp/not-a-dockerfile')) + + +class ReportTests(unittest.TestCase): + """Verify Markdown output for updates and no-op runs.""" + + def test_renders_markdown_update_report(self) -> None: + discovery = Discovery( + digest=NEW_DIGEST, + releases={'redis': (CURRENT['redis'], '6.3.1')}, + ) + plan = dependencies.plan_update( + dockerfile(), + discovery.run, + discovery.fetch, + ) + report = dependencies.render_report(plan) + self.assertTrue(report.startswith('## Dependency update report\n')) + self.assertIn( + '| Dependency | Current | Selected | Result |', + report, + ) + self.assertIn( + f'| {dependencies.BASE_NAME} | `{OLD_DIGEST}` ' + f'| `{NEW_DIGEST}` | Updated |', + report, + ) + self.assertIn( + f'| redis | `{CURRENT["redis"]}` | `6.3.1` | Updated |', + report, + ) + self.assertIn('**Updates:** 2', report) + self.assertTrue(report.endswith('Dockerfile pins were updated.')) + + def test_renders_explicit_noop_report(self) -> None: + discovery = Discovery() + plan = dependencies.plan_update( + dockerfile(), + discovery.run, + discovery.fetch, + ) + report = dependencies.render_report(plan) + self.assertIn('**Updates:** 0', report) + self.assertIn('No dependency updates were found.', report) + self.assertNotIn('| Updated |', report) + + +if __name__ == '__main__': + unittest.main() From e7846aa2f3913f6222842ec786ad0715bff60f5e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 15:01:04 +1200 Subject: [PATCH 02/19] feat: add dependency release orchestration helpers --- .github/scripts/automation.py | 361 ++++++++++++++++++++++++++++ .github/scripts/test_automation.py | 373 +++++++++++++++++++++++++++++ 2 files changed, 734 insertions(+) create mode 100644 .github/scripts/automation.py create mode 100644 .github/scripts/test_automation.py diff --git a/.github/scripts/automation.py b/.github/scripts/automation.py new file mode 100644 index 0000000..735298c --- /dev/null +++ b/.github/scripts/automation.py @@ -0,0 +1,361 @@ +"""Pure orchestration rules for dependency updates and patch releases.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum +from typing import Iterable, Sequence + + +_VERSION = re.compile( + r'(?P0|[1-9][0-9]*)\.' + r'(?P0|[1-9][0-9]*)\.' + r'(?P0|[1-9][0-9]*)' +) + + +class AutomationError(RuntimeError): + """Base error for a failed orchestration invariant.""" + + +class VersionMissingError(AutomationError): + """Raised when no stable version tag exists.""" + + +class VersionInvalidError(AutomationError): + """Raised when a required value is not a stable version.""" + + +class HeadChangedError(AutomationError): + """Raised when a pull request no longer points to the approved head.""" + + +class ApprovalMissingError(AutomationError): + """Raised when a pull request does not currently have approval.""" + + +class PullRequestUnavailableError(AutomationError): + """Raised when a pull request cannot currently be merged.""" + + +class TargetMismatchError(AutomationError): + """Raised when a tag or release points at an unexpected commit.""" + + +@dataclass(frozen=True, order=True) +class Version: + """An unprefixed stable MAJOR.MINOR.PATCH version.""" + + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> Version | None: + """Parse a stable version, returning None for unsupported tag names.""" + match = _VERSION.fullmatch(value) + if match is None: + return None + + try: + return cls( + major=int(match.group('major')), + minor=int(match.group('minor')), + patch=int(match.group('patch')), + ) + except ValueError: + return None + + def next_patch(self) -> Version: + """Return the immediately following patch version.""" + return Version(self.major, self.minor, self.patch + 1) + + def __str__(self) -> str: + return f'{self.major}.{self.minor}.{self.patch}' + + +def stable_versions(tags: Iterable[str]) -> tuple[Version, ...]: + """Return unique stable versions in semantic order.""" + versions = { + version + for tag in tags + if (version := Version.parse(tag)) is not None + } + return tuple(sorted(versions)) + + +def latest_version(tags: Iterable[str]) -> Version: + """Return the semantic maximum across all supplied remote tags.""" + versions = stable_versions(tags) + if not versions: + raise VersionMissingError('No stable remote version tag exists') + return versions[-1] + + +def next_patch(tags: Iterable[str]) -> Version: + """Compute the next patch from the semantic maximum remote tag.""" + return latest_version(tags).next_patch() + + +def newer_unreleased_tag( + tags: Iterable[str], + releases: Iterable[str], +) -> Version | None: + """Find the newest remote tag newer than every published release.""" + tagged = stable_versions(tags) + if not tagged: + return None + + published = stable_versions(releases) + threshold = published[-1] if published else None + candidates = [ + version + for version in tagged + if version not in published + and (threshold is None or version > threshold) + ] + return max(candidates, default=None) + + +def release_candidate( + tags: Iterable[str], + releases: Iterable[str], +) -> Version: + """Resume an unreleased newer tag or compute a fresh patch version.""" + tags = tuple(tags) + unreleased = newer_unreleased_tag(tags, releases) + return unreleased if unreleased is not None else next_patch(tags) + + +def patch_after_collision( + tags: Iterable[str], + collision: str, +) -> Version: + """Recompute a patch after refreshing tags following a create collision.""" + collided = Version.parse(collision) + if collided is None: + raise VersionInvalidError( + f'Collision tag {collision!r} is not a stable version' + ) + return next_patch((*tags, str(collided))) + + +def _require_aware(value: datetime, name: str) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f'{name} must include a timezone') + + +@dataclass(frozen=True) +class Deadline: + """An injected absolute deadline used without sleeping.""" + + at: datetime + + def __post_init__(self) -> None: + _require_aware(self.at, 'Deadline') + + @classmethod + def after(cls, now: datetime, timeout: timedelta) -> Deadline: + """Create a deadline relative to injected current time.""" + _require_aware(now, 'Current time') + if timeout <= timedelta(): + raise ValueError('Timeout must be positive') + return cls(now + timeout) + + def expired(self, now: datetime) -> bool: + """Return whether the deadline has been reached.""" + _require_aware(now, 'Current time') + return now >= self.at + + def remaining(self, now: datetime) -> timedelta: + """Return remaining time, clamped at zero.""" + _require_aware(now, 'Current time') + return max(self.at - now, timedelta()) + + +@dataclass(frozen=True) +class Run: + """Normalized workflow run state supplied by a GitHub adapter.""" + + identifier: int + workflow: str + event: str + head: str + branch: str + created: datetime + attempt: int + status: str + conclusion: str | None + + def __post_init__(self) -> None: + _require_aware(self.created, 'Workflow run creation time') + if self.attempt < 1: + raise ValueError('Workflow run attempt must be positive') + + +class WorkflowState(Enum): + """A closed set of states used by polling orchestration.""" + + MISSING = 'missing' + PENDING = 'pending' + SUCCEEDED = 'succeeded' + FAILED = 'failed' + CANCELLED = 'cancelled' + TIMED_OUT = 'timed_out' + + +def select_run( + runs: Sequence[Run], + *, + workflow: str, + event: str, + head: str, + branch: str, + created: datetime, +) -> Run | None: + """Select the newest exact run and its newest rerun attempt.""" + _require_aware(created, 'Workflow run boundary') + matches = [ + run + for run in runs + if run.workflow == workflow + and run.event == event + and run.head == head + and run.branch == branch + and run.created >= created + ] + return max( + matches, + key=lambda run: (run.created, run.identifier, run.attempt), + default=None, + ) + + +def workflow_state( + runs: Sequence[Run], + *, + workflow: str, + event: str, + head: str, + branch: str, + created: datetime, + deadline: Deadline, + now: datetime, +) -> WorkflowState: + """Evaluate one exact workflow run without polling or sleeping.""" + run = select_run( + runs, + workflow=workflow, + event=event, + head=head, + branch=branch, + created=created, + ) + if run is None: + if deadline.expired(now): + return WorkflowState.TIMED_OUT + return WorkflowState.MISSING + + if run.status != 'completed': + if deadline.expired(now): + return WorkflowState.TIMED_OUT + return WorkflowState.PENDING + + if run.conclusion == 'success': + return WorkflowState.SUCCEEDED + if run.conclusion == 'cancelled': + return WorkflowState.CANCELLED + if run.conclusion == 'timed_out': + return WorkflowState.TIMED_OUT + return WorkflowState.FAILED + + +class ReviewDecision(Enum): + """Normalized current pull request review decision.""" + + APPROVED = 'approved' + CHANGES_REQUESTED = 'changes_requested' + REVIEW_REQUIRED = 'review_required' + + +@dataclass(frozen=True) +class PullRequest: + """Current pull request state supplied immediately before merging.""" + + number: int + head: str + state: str + review: ReviewDecision + + +def validate_pull_request( + pull_request: PullRequest, + expected_head: str, +) -> None: + """Require the unchanged head and a current approval before merging.""" + if pull_request.head != expected_head: + raise HeadChangedError( + f'Pull request #{pull_request.number} head changed from ' + f'{expected_head} to {pull_request.head}' + ) + if pull_request.state != 'open': + raise PullRequestUnavailableError( + f'Pull request #{pull_request.number} is {pull_request.state}' + ) + if pull_request.review is not ReviewDecision.APPROVED: + raise ApprovalMissingError( + f'Pull request #{pull_request.number} is not currently approved' + ) + + +@dataclass(frozen=True) +class Tag: + """A remote tag resolved to its target commit.""" + + name: str + target: str + + +@dataclass(frozen=True) +class Release: + """A published release resolved to its tag target commit.""" + + tag: str + target: str + + +def validate_tag_target( + tag: Tag, + *, + expected_name: str, + expected_target: str, +) -> None: + """Require a tag with the expected name and exact target commit.""" + if tag.name != expected_name: + raise TargetMismatchError( + f'Expected tag {expected_name}, found {tag.name}' + ) + if tag.target != expected_target: + raise TargetMismatchError( + f'Tag {tag.name} targets {tag.target}, expected {expected_target}' + ) + + +def validate_release_target( + release: Release, + *, + expected_tag: str, + expected_target: str, +) -> None: + """Require a release for the expected tag and exact target commit.""" + if release.tag != expected_tag: + raise TargetMismatchError( + f'Expected release for {expected_tag}, found {release.tag}' + ) + if release.target != expected_target: + raise TargetMismatchError( + f'Release {release.tag} targets {release.target}, ' + f'expected {expected_target}' + ) diff --git a/.github/scripts/test_automation.py b/.github/scripts/test_automation.py new file mode 100644 index 0000000..b68a8cf --- /dev/null +++ b/.github/scripts/test_automation.py @@ -0,0 +1,373 @@ +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).parent)) + +from automation import ( # noqa: E402 + ApprovalMissingError, + Deadline, + HeadChangedError, + PullRequest, + Release, + ReviewDecision, + Run, + Tag, + TargetMismatchError, + Version, + VersionInvalidError, + VersionMissingError, + WorkflowState, + latest_version, + newer_unreleased_tag, + next_patch, + patch_after_collision, + release_candidate, + select_run, + stable_versions, + validate_pull_request, + validate_release_target, + validate_tag_target, + workflow_state, +) + + +UTC = timezone.utc +START = datetime(2026, 7, 24, 8, 0, tzinfo=UTC) + + +def run( + *, + identifier: int = 1, + workflow: str = 'Build and Push', + event: str = 'push', + head: str = 'approved-head', + branch: str = 'main', + created: datetime = START, + attempt: int = 1, + status: str = 'completed', + conclusion: str | None = 'success', +) -> Run: + return Run( + identifier=identifier, + workflow=workflow, + event=event, + head=head, + branch=branch, + created=created, + attempt=attempt, + status=status, + conclusion=conclusion, + ) + + +class VersionTest(unittest.TestCase): + def test_orders_stable_versions_semantically(self) -> None: + self.assertEqual( + ( + Version(1, 2, 99), + Version(1, 10, 0), + Version(2, 0, 0), + ), + stable_versions( + ['1.10.0', '2.0.0', '1.2.99', '1.10.0'] + ), + ) + + def test_ignores_nonstable_and_prefixed_tags(self) -> None: + self.assertEqual( + (Version(0, 0, 0), Version(12, 34, 56)), + stable_versions( + [ + '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', + '', + ] + ), + ) + + def test_next_patch_uses_semantic_maximum_of_all_remote_tags(self) -> None: + self.assertEqual( + Version(2, 0, 1), + next_patch( + ['1.999.999', '2.0.0', 'v9.0.0', '9.0.0-rc.1'] + ), + ) + + def test_latest_version_requires_a_stable_remote_tag(self) -> None: + with self.assertRaises(VersionMissingError): + latest_version(['v1.0.0', '1.0.0-rc.1']) + + def test_resumes_newest_tag_newer_than_latest_release(self) -> None: + tags = ['1.4.1', '1.4.2', '1.4.3', '1.4.4'] + releases = ['1.4.1', '1.4.3'] + + self.assertEqual( + Version(1, 4, 4), + newer_unreleased_tag(tags, releases), + ) + self.assertEqual( + Version(1, 4, 4), + release_candidate(tags, releases), + ) + + def test_ignores_unreleased_holes_older_than_latest_release(self) -> None: + self.assertIsNone( + newer_unreleased_tag( + ['1.4.1', '1.4.2', '1.4.3'], + ['1.4.1', '1.4.3'], + ) + ) + + def test_computes_new_patch_when_every_newer_tag_is_released(self) -> None: + self.assertEqual( + Version(1, 4, 5), + release_candidate( + ['1.4.3', '1.4.4'], + ['1.4.3', '1.4.4'], + ), + ) + + def test_recomputes_after_collision_from_refreshed_remote_tags(self) -> None: + self.assertEqual( + Version(1, 4, 8), + patch_after_collision( + ['1.4.4', '1.4.5', '1.4.7'], + '1.4.5', + ), + ) + + def test_rejects_malformed_collision_tag(self) -> None: + with self.assertRaises(VersionInvalidError): + patch_after_collision(['1.4.4'], 'v1.4.5') + + +class DeadlineTest(unittest.TestCase): + def test_uses_injected_time_and_clamps_remaining_time(self) -> None: + deadline = Deadline.after(START, timedelta(minutes=10)) + + self.assertFalse(deadline.expired(START + timedelta(minutes=9))) + self.assertTrue(deadline.expired(START + timedelta(minutes=10))) + self.assertEqual( + timedelta(minutes=1), + deadline.remaining(START + timedelta(minutes=9)), + ) + self.assertEqual( + timedelta(), + deadline.remaining(START + timedelta(minutes=11)), + ) + + def test_rejects_naive_times_and_nonpositive_timeouts(self) -> None: + with self.assertRaises(ValueError): + Deadline(datetime(2026, 7, 24, 8, 0)) + with self.assertRaises(ValueError): + Deadline.after(START, timedelta()) + + +class WorkflowTest(unittest.TestCase): + def setUp(self) -> None: + self.deadline = Deadline.after(START, timedelta(minutes=30)) + + def state( + self, + runs: list[Run], + *, + now: datetime = START, + ) -> WorkflowState: + return workflow_state( + runs, + workflow='Build and Push', + event='push', + head='approved-head', + branch='main', + created=START, + deadline=self.deadline, + now=now, + ) + + def test_selects_only_exact_runs_at_or_after_boundary(self) -> None: + expected = run(identifier=8, created=START + timedelta(seconds=1)) + runs = [ + run(identifier=1, workflow='Dive Test'), + run(identifier=2, event='pull_request'), + run(identifier=3, head='other-head'), + run(identifier=4, branch='feature'), + run(identifier=5, created=START - timedelta(microseconds=1)), + expected, + ] + + self.assertIs( + expected, + select_run( + runs, + workflow='Build and Push', + event='push', + head='approved-head', + branch='main', + created=START, + ), + ) + + def test_selects_newest_run_then_newest_rerun_attempt(self) -> None: + earlier = START + timedelta(seconds=1) + later = START + timedelta(seconds=2) + expected = run(identifier=20, created=later, attempt=2) + runs = [ + run(identifier=10, created=earlier, attempt=3), + run( + identifier=20, + created=later, + attempt=1, + conclusion='failure', + ), + expected, + ] + + self.assertIs( + expected, + select_run( + runs, + workflow='Build and Push', + event='push', + head='approved-head', + branch='main', + created=START, + ), + ) + + def test_reports_missing_run_before_deadline(self) -> None: + self.assertIs(WorkflowState.MISSING, self.state([])) + + def test_reports_successful_run(self) -> None: + self.assertIs(WorkflowState.SUCCEEDED, self.state([run()])) + + def test_reports_failed_run(self) -> None: + self.assertIs( + WorkflowState.FAILED, + self.state([run(conclusion='failure')]), + ) + + def test_reports_cancelled_run(self) -> None: + self.assertIs( + WorkflowState.CANCELLED, + self.state([run(conclusion='cancelled')]), + ) + + def test_reports_run_timeout_conclusion(self) -> None: + self.assertIs( + WorkflowState.TIMED_OUT, + self.state([run(conclusion='timed_out')]), + ) + + def test_reports_pending_run_before_deadline(self) -> None: + self.assertIs( + WorkflowState.PENDING, + self.state( + [run(status='in_progress', conclusion=None)], + now=START + timedelta(minutes=29), + ), + ) + + def test_times_out_pending_run_at_deadline(self) -> None: + self.assertIs( + WorkflowState.TIMED_OUT, + self.state( + [run(status='queued', conclusion=None)], + now=START + timedelta(minutes=30), + ), + ) + + def test_times_out_missing_run_at_deadline(self) -> None: + self.assertIs( + WorkflowState.TIMED_OUT, + self.state([], now=START + timedelta(minutes=30)), + ) + + def test_preserves_terminal_failure_after_deadline(self) -> None: + self.assertIs( + WorkflowState.FAILED, + self.state( + [run(conclusion='failure')], + now=START + timedelta(minutes=31), + ), + ) + + +class PullRequestTest(unittest.TestCase): + def test_accepts_current_approved_head(self) -> None: + validate_pull_request( + PullRequest( + number=75, + head='approved-head', + state='open', + review=ReviewDecision.APPROVED, + ), + 'approved-head', + ) + + def test_rejects_changed_head_even_if_currently_approved(self) -> None: + with self.assertRaises(HeadChangedError): + validate_pull_request( + PullRequest( + number=75, + head='changed-head', + state='open', + review=ReviewDecision.APPROVED, + ), + 'approved-head', + ) + + def test_rejects_missing_current_approval(self) -> None: + with self.assertRaises(ApprovalMissingError): + validate_pull_request( + PullRequest( + number=75, + head='approved-head', + state='open', + review=ReviewDecision.REVIEW_REQUIRED, + ), + 'approved-head', + ) + + +class TargetTest(unittest.TestCase): + def test_accepts_exact_tag_and_release_targets(self) -> None: + validate_tag_target( + Tag(name='1.4.5', target='merged-head'), + expected_name='1.4.5', + expected_target='merged-head', + ) + validate_release_target( + Release(tag='1.4.5', target='merged-head'), + expected_tag='1.4.5', + expected_target='merged-head', + ) + + def test_rejects_tag_target_mismatch(self) -> None: + with self.assertRaises(TargetMismatchError): + validate_tag_target( + Tag(name='1.4.5', target='wrong-head'), + expected_name='1.4.5', + expected_target='merged-head', + ) + + def test_rejects_release_target_mismatch(self) -> None: + with self.assertRaises(TargetMismatchError): + validate_release_target( + Release(tag='1.4.5', target='wrong-head'), + expected_tag='1.4.5', + expected_target='merged-head', + ) + + +if __name__ == '__main__': + unittest.main() From 70211f4a4042e4458ec7312b189371e3ca50ae1f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 14:58:28 +1200 Subject: [PATCH 03/19] (fix): avoid duplicate release builds --- .github/workflows/build-and-push.yml | 2 -- 1 file changed, 2 deletions(-) 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 From 730ef51253ad0dd5909fb44d1156f5e221e0b03e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 15:12:08 +1200 Subject: [PATCH 04/19] feat: add weekly dependency automation --- .github/workflows/dependencies.yml | 588 +++++++++++++++++++++++++++++ 1 file changed, 588 insertions(+) create mode 100644 .github/workflows/dependencies.yml diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 0000000..be9b55a --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,588 @@ +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: 180 + steps: + - name: Checkout repository + uses: actions/checkout@v6.1.0 + with: + token: ${{ github.token }} + persist-credentials: false + + - name: Run offline tests + run: | + python3 -m unittest discover \ + --start-directory .github/scripts \ + --pattern 'test_*.py' \ + --verbose + + - name: Update dependencies + id: update + run: | + report="${RUNNER_TEMP}/dependencies.md" + python3 .github/scripts/dependencies.py | 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 '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 + )" + + 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 }}' + body="${RUNNER_TEMP}/pull-request.md" + { + 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,createdAt,headRefName,headRefOid,number,state \ + --jq '.' + )" + PULL="${pull}" python3 - "${branch}" "${head}" <<'PY' + import json + import os + import sys + + branch, head = sys.argv[1:] + pull = json.loads(os.environ['PULL']) + if pull['baseRefName'] != 'main': + raise RuntimeError('Pull request does not target main') + if pull['headRefName'] != branch or pull['headRefOid'] != head: + raise RuntimeError('Pull request does not use the pushed head') + if pull['state'] != 'OPEN': + raise RuntimeError('Pull request is not open') + PY + + { + printf 'number=%s\n' "$( + PULL="${pull}" python3 -c \ + 'import json, os; print(json.loads(os.environ["PULL"])["number"])' + )" + 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: | + python3 - <<'PY' + import json + import os + import subprocess + import sys + import time + from datetime import datetime, timedelta, timezone + from pathlib import Path + + sys.path.insert(0, str(Path('.github/scripts').resolve())) + + from automation import ( + Deadline, + Run, + WorkflowState, + workflow_state, + ) + + branch = os.environ['BRANCH'] + boundary = datetime.fromisoformat( + os.environ['CREATED'].replace('Z', '+00:00') + ) + head = os.environ['HEAD'] + repository = os.environ['GITHUB_REPOSITORY'] + version = os.environ['GITHUB_API_VERSION'] + deadline = Deadline.after( + datetime.now(timezone.utc), + timedelta(minutes=120), + ) + 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'), + ) + terminal_failures = { + WorkflowState.CANCELLED, + WorkflowState.FAILED, + WorkflowState.TIMED_OUT, + } + + def fetch(filename: str) -> tuple[Run, ...]: + command = ( + 'gh', + 'api', + '-X', + 'GET', + ( + f'repos/{repository}/actions/workflows/' + f'{filename}/runs' + ), + '-H', + f'X-GitHub-Api-Version: {version}', + '-f', + f'branch={branch}', + '-f', + f'head_sha={head}', + '-f', + f'created=>={os.environ["CREATED"]}', + '-f', + 'per_page=100', + ) + result = subprocess.run( + command, + check=True, + stdout=subprocess.PIPE, + text=True, + ) + payload = json.loads(result.stdout) + return tuple( + Run( + identifier=item['id'], + workflow=item['name'], + event=item['event'], + head=item['head_sha'], + branch=item['head_branch'], + created=datetime.fromisoformat( + item['created_at'].replace('Z', '+00:00') + ), + attempt=item['run_attempt'], + status=item['status'], + conclusion=item['conclusion'], + ) + for item in payload['workflow_runs'] + ) + + previous = None + while True: + now = datetime.now(timezone.utc) + states = { + name: workflow_state( + fetch(filename), + workflow=name, + event=event, + head=head, + branch=branch, + created=boundary, + deadline=deadline, + now=now, + ) + for filename, name, event in workflows + } + snapshot = tuple( + (name, state.value) for name, state in states.items() + ) + if snapshot != previous: + print( + ', '.join( + f'{name}: {state}' for name, state in snapshot + ), + flush=True, + ) + previous = snapshot + + failed = { + name: state + for name, state in states.items() + if state in terminal_failures + } + if failed: + detail = ', '.join( + f'{name}: {state.value}' + for name, state in failed.items() + ) + raise RuntimeError(f'CI did not succeed: {detail}') + if all( + state is WorkflowState.SUCCEEDED + for state in states.values() + ): + break + + remaining = deadline.remaining( + datetime.now(timezone.utc) + ).total_seconds() + time.sleep(min(20, max(remaining, 0))) + PY + + - 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: | + number='${{ steps.pull.outputs.number }}' + head='${{ steps.push.outputs.head }}' + pull="$( + gh pr view "${number}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json baseRefName,headRefOid,mergeable,number,reviewDecision,state \ + --jq '.' + )" + PULL="${pull}" python3 - "${head}" <<'PY' + import json + import os + import sys + from pathlib import Path + + sys.path.insert(0, str(Path('.github/scripts').resolve())) + + from automation import ( + PullRequest, + PullRequestUnavailableError, + ReviewDecision, + validate_pull_request, + ) + + expected = sys.argv[1] + payload = json.loads(os.environ['PULL']) + if payload['baseRefName'] != 'main': + raise PullRequestUnavailableError( + 'Pull request does not target main' + ) + try: + review = ReviewDecision(payload['reviewDecision'].lower()) + except ValueError as error: + raise PullRequestUnavailableError( + 'Pull request has no current approval' + ) from error + validate_pull_request( + PullRequest( + number=payload['number'], + head=payload['headRefOid'], + state=payload['state'].lower(), + review=review, + ), + expected, + ) + if payload['mergeable'] != 'MERGEABLE': + raise PullRequestUnavailableError( + 'Pull request is not currently mergeable' + ) + PY + + if ! gh pr merge "${number}" \ + --repo "${GITHUB_REPOSITORY}" \ + --squash \ + --delete-branch \ + --match-head-commit "${head}"; then + echo 'The merge command failed; checking the pull request state.' >&2 + fi + + merged="$( + gh pr view "${number}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json mergeCommit,state \ + --jq 'select(.state == "MERGED") | .mergeCommit.oid' + )" + if [[ ! "${merged}" =~ ^[0-9a-f]{40,64}$ ]]; then + echo 'Pull request did not produce a merge commit.' >&2 + exit 1 + fi + printf 'head=%s\n' "${merged}" >> "${GITHUB_OUTPUT}" + + - name: Create and verify lightweight tag + id: tag + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + MERGE_HEAD: ${{ steps.merge.outputs.head }} + run: | + python3 - <<'PY' + import json + import os + import subprocess + import sys + from pathlib import Path + + sys.path.insert(0, str(Path('.github/scripts').resolve())) + + from automation import ( + Tag, + next_patch, + patch_after_collision, + validate_tag_target, + ) + + repository = os.environ['GITHUB_REPOSITORY'] + version = os.environ['GITHUB_API_VERSION'] + merge = os.environ['MERGE_HEAD'] + header = f'X-GitHub-Api-Version: {version}' + + def tags() -> tuple[str, ...]: + result = subprocess.run( + ( + 'gh', + 'api', + '--paginate', + '-X', + 'GET', + ( + f'repos/{repository}/git/' + 'matching-refs/tags/' + ), + '-H', + header, + '-f', + 'per_page=100', + '--jq', + '.[].ref', + ), + check=True, + stdout=subprocess.PIPE, + text=True, + ) + prefix = 'refs/tags/' + return tuple( + line[len(prefix):] + for line in result.stdout.splitlines() + if line.startswith(prefix) + ) + + def create(tag: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ( + 'gh', + 'api', + '-X', + 'POST', + f'repos/{repository}/git/refs', + '-H', + header, + '-f', + f'ref=refs/tags/{tag}', + '-f', + f'sha={merge}', + '--silent', + ), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def read(tag: str) -> dict[str, object] | None: + result = subprocess.run( + ( + 'gh', + 'api', + '-X', + 'GET', + f'repos/{repository}/git/ref/tags/{tag}', + '-H', + header, + ), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return json.loads(result.stdout) if result.returncode == 0 else None + + available = tags() + candidate = str(next_patch(available)) + while True: + result = create(candidate) + if result.returncode == 0: + break + existing = read(candidate) + if existing is None: + sys.stderr.write(result.stderr) + raise RuntimeError(f'Failed to create tag {candidate}') + target = existing.get('object') + if ( + isinstance(target, dict) + and target.get('type') == 'commit' + and target.get('sha') == merge + ): + break + print(f'Tag {candidate} collided; refreshing remote tags.') + available = tags() + candidate = str( + patch_after_collision(available, candidate) + ) + + payload = read(candidate) + if payload is None: + raise RuntimeError(f'Tag {candidate} is missing after creation') + target = payload['object'] + if not isinstance(target, dict) or target.get('type') != 'commit': + raise RuntimeError(f'Tag {candidate} is not lightweight') + validate_tag_target( + Tag(name=candidate, target=str(target.get('sha'))), + expected_name=candidate, + expected_target=merge, + ) + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output: + print(f'name={candidate}', file=output) + PY + + - name: Create and verify release + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + MERGE_HEAD: ${{ steps.merge.outputs.head }} + TAG: ${{ steps.tag.outputs.name }} + run: | + gh release create "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --target "${MERGE_HEAD}" \ + --generate-notes \ + --latest \ + --fail-on-no-commits + + release="$( + gh api \ + -X GET \ + -H "X-GitHub-Api-Version: ${GITHUB_API_VERSION}" \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" + )" + tag="$( + gh api \ + -X GET \ + -H "X-GitHub-Api-Version: ${GITHUB_API_VERSION}" \ + "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" + )" + RELEASE="${release}" TAG_REF="${tag}" python3 - <<'PY' + import json + import os + import sys + from pathlib import Path + + sys.path.insert(0, str(Path('.github/scripts').resolve())) + + from automation import ( + Release, + Tag, + validate_release_target, + validate_tag_target, + ) + + expected_tag = os.environ['TAG'] + expected_target = os.environ['MERGE_HEAD'] + release = json.loads(os.environ['RELEASE']) + tag = json.loads(os.environ['TAG_REF']) + target = tag['object'] + if not isinstance(target, dict) or target.get('type') != 'commit': + raise RuntimeError(f'Tag {expected_tag} is not lightweight') + actual_target = str(target.get('sha')) + validate_tag_target( + Tag(name=tag['ref'].removeprefix('refs/tags/'), target=actual_target), + expected_name=expected_tag, + expected_target=expected_target, + ) + validate_release_target( + Release(tag=release['tag_name'], target=actual_target), + expected_tag=expected_tag, + expected_target=expected_target, + ) + if release['draft'] or release['prerelease']: + raise RuntimeError('Release is not a published stable release') + PY From 21b368f73f941babcc7575f7916662f0299fd564 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 15:17:19 +1200 Subject: [PATCH 05/19] (fix): wire dependency release recovery --- .github/workflows/dependencies.yml | 32 +++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index be9b55a..bc960e4 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -82,6 +82,7 @@ jobs: 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' \ @@ -405,8 +406,8 @@ jobs: from automation import ( Tag, - next_patch, patch_after_collision, + release_candidate, validate_tag_target, ) @@ -467,6 +468,28 @@ jobs: text=True, ) + def releases() -> tuple[str, ...]: + result = subprocess.run( + ( + 'gh', + 'api', + '--paginate', + '-X', + 'GET', + f'repos/{repository}/releases', + '-H', + header, + '-f', + 'per_page=100', + '--jq', + '.[].tag_name', + ), + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return tuple(result.stdout.splitlines()) + def read(tag: str) -> dict[str, object] | None: result = subprocess.run( ( @@ -486,7 +509,7 @@ jobs: return json.loads(result.stdout) if result.returncode == 0 else None available = tags() - candidate = str(next_patch(available)) + candidate = str(release_candidate(available, releases())) while True: result = create(candidate) if result.returncode == 0: @@ -515,7 +538,10 @@ jobs: if not isinstance(target, dict) or target.get('type') != 'commit': raise RuntimeError(f'Tag {candidate} is not lightweight') validate_tag_target( - Tag(name=candidate, target=str(target.get('sha'))), + Tag( + name=str(payload.get('ref', '')).removeprefix('refs/tags/'), + target=str(target.get('sha')), + ), expected_name=candidate, expected_target=merge, ) From 97b90f7a7a85d75151b03ea200ccd59b605dff18 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 15:36:17 +1200 Subject: [PATCH 06/19] (fix): harden dependency updater validation --- .github/scripts/dependencies.py | 121 ++++++---------- .github/scripts/dependency/Change.py | 18 +++ .github/scripts/dependency/CommandRunner.py | 14 ++ .github/scripts/dependency/Dependency.py | 16 +++ .github/scripts/dependency/Fetcher.py | 14 ++ .github/scripts/dependency/GitSource.py | 12 ++ .github/scripts/dependency/PeclSource.py | 12 ++ .github/scripts/dependency/Pin.py | 15 ++ .github/scripts/dependency/Plan.py | 19 +++ .github/scripts/dependency/Source.py | 9 ++ .github/scripts/dependency/UpdateError.py | 5 + .github/scripts/dependency/Version.py | 14 ++ .github/scripts/dependency/__init__.py | 1 + .github/scripts/test_dependencies.py | 144 ++++++++++++++++++-- 14 files changed, 324 insertions(+), 90 deletions(-) create mode 100644 .github/scripts/dependency/Change.py create mode 100644 .github/scripts/dependency/CommandRunner.py create mode 100644 .github/scripts/dependency/Dependency.py create mode 100644 .github/scripts/dependency/Fetcher.py create mode 100644 .github/scripts/dependency/GitSource.py create mode 100644 .github/scripts/dependency/PeclSource.py create mode 100644 .github/scripts/dependency/Pin.py create mode 100644 .github/scripts/dependency/Plan.py create mode 100644 .github/scripts/dependency/Source.py create mode 100644 .github/scripts/dependency/UpdateError.py create mode 100644 .github/scripts/dependency/Version.py create mode 100644 .github/scripts/dependency/__init__.py diff --git a/.github/scripts/dependencies.py b/.github/scripts/dependencies.py index f462516..baa415b 100644 --- a/.github/scripts/dependencies.py +++ b/.github/scripts/dependencies.py @@ -9,97 +9,42 @@ import sys import urllib.request import xml.etree.ElementTree as ElementTree -from collections.abc import Callable, Iterable, Sequence -from dataclasses import dataclass +from collections.abc import Iterable, Sequence from pathlib import Path +from dependency.Change import Change +from dependency.CommandRunner import CommandRunner +from dependency.Dependency import Dependency +from dependency.Fetcher import Fetcher +from dependency.GitSource import GitSource +from dependency.PeclSource import PeclSource +from dependency.Pin import Pin +from dependency.Plan import Plan +from dependency.Source import Source +from dependency.UpdateError import UpdateError +from dependency.Version import Version + BASE_NAME = 'php:8.5-alpine' BASE_PATTERN = re.compile( rf'{re.escape(BASE_NAME)}@(sha256:[0-9a-f]{{64}})' ) +DECLARATION_LINE_PATTERN = re.compile( + r'(?m)^[ \t]*(?:(?:ARG|ENV)[ \t]+[^\r\n]*|' + r'PHP_[A-Za-z0-9_]+_VERSION[^\r\n]*)$' +) +DECLARATION_PATTERN = re.compile( + r'(? bool: - return self.current != self.latest - - -@dataclass(frozen=True, slots=True) -class Plan: - """The complete in-memory Dockerfile update.""" - - content: str - changes: tuple[Change, ...] - - @property - def changed(self) -> bool: - return any(change.changed for change in self.changes) - - DEPENDENCIES: tuple[Dependency, ...] = ( Dependency( 'brotli', @@ -336,6 +281,20 @@ def discover_releases( def read_pins(content: str) -> tuple[Pin, ...]: """Validate and locate every expected Dockerfile declaration once.""" + expected = {dependency.variable for dependency in DEPENDENCIES} + declared = { + match.group(1) + for line in DECLARATION_LINE_PATTERN.finditer(content) + for match in DECLARATION_PATTERN.finditer(line.group(0)) + } + unknown = tuple(sorted(declared - expected)) + if unknown: + raise UpdateError( + 'Unknown PHP extension version ' + f'declaration{"s" if len(unknown) != 1 else ""}: ' + f'{", ".join(unknown)}' + ) + image_expression = re.compile( r'(?m)^[ \t]*ARG[ \t]+BASE_IMAGE="([^"\r\n]+)"[ \t]*$' ) diff --git a/.github/scripts/dependency/Change.py b/.github/scripts/dependency/Change.py new file mode 100644 index 0000000..5e97ea9 --- /dev/null +++ b/.github/scripts/dependency/Change.py @@ -0,0 +1,18 @@ +"""A dependency update selection.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Change: + """The current and selected value for one dependency.""" + + name: str + current: str + latest: str + + @property + def changed(self) -> bool: + return self.current != self.latest diff --git a/.github/scripts/dependency/CommandRunner.py b/.github/scripts/dependency/CommandRunner.py new file mode 100644 index 0000000..1676900 --- /dev/null +++ b/.github/scripts/dependency/CommandRunner.py @@ -0,0 +1,14 @@ +"""A dependency discovery command runner.""" + +from __future__ import annotations + +from typing import Protocol + + +class CommandRunner(Protocol): + """Execute one dependency discovery command.""" + + def __call__(self, command: tuple[str, ...]) -> str: + """Return the command's standard output.""" + + ... diff --git a/.github/scripts/dependency/Dependency.py b/.github/scripts/dependency/Dependency.py new file mode 100644 index 0000000..06f260c --- /dev/null +++ b/.github/scripts/dependency/Dependency.py @@ -0,0 +1,16 @@ +"""A pinned Dockerfile dependency.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dependency.Source import Source + + +@dataclass(frozen=True, slots=True) +class Dependency: + """A Dockerfile variable and its authoritative release source.""" + + name: str + variable: str + source: Source diff --git a/.github/scripts/dependency/Fetcher.py b/.github/scripts/dependency/Fetcher.py new file mode 100644 index 0000000..95abfe6 --- /dev/null +++ b/.github/scripts/dependency/Fetcher.py @@ -0,0 +1,14 @@ +"""A dependency release feed fetcher.""" + +from __future__ import annotations + +from typing import Protocol + + +class Fetcher(Protocol): + """Fetch one authoritative dependency release feed.""" + + def __call__(self, url: str) -> bytes: + """Return the response body.""" + + ... diff --git a/.github/scripts/dependency/GitSource.py b/.github/scripts/dependency/GitSource.py new file mode 100644 index 0000000..edf9146 --- /dev/null +++ b/.github/scripts/dependency/GitSource.py @@ -0,0 +1,12 @@ +"""A git release source.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class GitSource: + """A git repository whose exact version tags are release candidates.""" + + url: str diff --git a/.github/scripts/dependency/PeclSource.py b/.github/scripts/dependency/PeclSource.py new file mode 100644 index 0000000..3ba0815 --- /dev/null +++ b/.github/scripts/dependency/PeclSource.py @@ -0,0 +1,12 @@ +"""A PECL release source.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PeclSource: + """A PECL stable-release feed.""" + + url: str diff --git a/.github/scripts/dependency/Pin.py b/.github/scripts/dependency/Pin.py new file mode 100644 index 0000000..4dfd01f --- /dev/null +++ b/.github/scripts/dependency/Pin.py @@ -0,0 +1,15 @@ +"""A located Dockerfile dependency pin.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Pin: + """An exact declaration location in the Dockerfile.""" + + name: str + current: str + start: int + end: int diff --git a/.github/scripts/dependency/Plan.py b/.github/scripts/dependency/Plan.py new file mode 100644 index 0000000..7319533 --- /dev/null +++ b/.github/scripts/dependency/Plan.py @@ -0,0 +1,19 @@ +"""A complete dependency update plan.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dependency.Change import Change + + +@dataclass(frozen=True, slots=True) +class Plan: + """The complete in-memory Dockerfile update.""" + + content: str + changes: tuple[Change, ...] + + @property + def changed(self) -> bool: + return any(change.changed for change in self.changes) diff --git a/.github/scripts/dependency/Source.py b/.github/scripts/dependency/Source.py new file mode 100644 index 0000000..5dce78e --- /dev/null +++ b/.github/scripts/dependency/Source.py @@ -0,0 +1,9 @@ +"""Authoritative dependency release source types.""" + +from __future__ import annotations + +from dependency.GitSource import GitSource +from dependency.PeclSource import PeclSource + + +Source = GitSource | PeclSource diff --git a/.github/scripts/dependency/UpdateError.py b/.github/scripts/dependency/UpdateError.py new file mode 100644 index 0000000..c31304d --- /dev/null +++ b/.github/scripts/dependency/UpdateError.py @@ -0,0 +1,5 @@ +"""Dependency updater failures.""" + + +class UpdateError(RuntimeError): + """Raised when dependency discovery or Dockerfile validation fails.""" diff --git a/.github/scripts/dependency/Version.py b/.github/scripts/dependency/Version.py new file mode 100644 index 0000000..490bf63 --- /dev/null +++ b/.github/scripts/dependency/Version.py @@ -0,0 +1,14 @@ +"""A stable semantic version.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, order=True, slots=True) +class Version: + """A stable semantic release.""" + + major: int + minor: int + patch: int diff --git a/.github/scripts/dependency/__init__.py b/.github/scripts/dependency/__init__.py new file mode 100644 index 0000000..2fd9592 --- /dev/null +++ b/.github/scripts/dependency/__init__.py @@ -0,0 +1 @@ +"""Dependency updater domain models.""" diff --git a/.github/scripts/test_dependencies.py b/.github/scripts/test_dependencies.py index f195308..5ac9893 100644 --- a/.github/scripts/test_dependencies.py +++ b/.github/scripts/test_dependencies.py @@ -3,6 +3,9 @@ from __future__ import annotations +import ast +import inspect +import re import sys import tempfile import unittest @@ -29,6 +32,38 @@ 'yaml': '2.3.0', 'zstd': '0.15.2', } +DECLARATIONS: tuple[tuple[str, str], ...] = ( + ('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'), +) +EXPECTED_DOCKERFILE_DECLARATIONS = frozenset( + ( + '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', + ) +) OLD_DIGEST = 'sha256:' + ('1' * 64) NEW_DIGEST = 'sha256:' + ('2' * 64) @@ -43,16 +78,10 @@ def dockerfile() -> str: '', 'ENV \\', ] - regular = tuple( - dependency - for dependency in dependencies.DEPENDENCIES - if dependency.name != 'xdebug' - ) - for index, dependency in enumerate(regular): - suffix = ' \\' if index < len(regular) - 1 else '' + for index, (name, variable) in enumerate(DECLARATIONS): + suffix = ' \\' if index < len(DECLARATIONS) - 1 else '' lines.append( - f' {dependency.variable}="{CURRENT[dependency.name]}"' - f'{suffix}' + f' {variable}="{CURRENT[name]}"{suffix}' ) lines.extend( ( @@ -198,6 +227,39 @@ def test_source_definitions_are_immutable(self) -> None: with self.assertRaises(FrozenInstanceError): source.url = 'https://example.test/changed' + def test_domain_classes_are_in_matching_files(self) -> None: + classes = ( + dependencies.Change, + dependencies.CommandRunner, + dependencies.Dependency, + dependencies.Fetcher, + dependencies.GitSource, + dependencies.PeclSource, + dependencies.Pin, + dependencies.Plan, + dependencies.UpdateError, + dependencies.Version, + ) + for domain_class in classes: + with self.subTest(domain_class=domain_class.__name__): + path = inspect.getsourcefile(domain_class) + self.assertIsNotNone(path) + self.assertEqual( + f'{domain_class.__name__}.py', + Path(path).name, + ) + + path = Path(dependencies.__file__) + module = ast.parse(path.read_text(encoding='utf-8')) + self.assertEqual( + [], + [ + node.name + for node in module.body + if isinstance(node, ast.ClassDef) + ], + ) + class VersionTests(unittest.TestCase): """Verify stable semantic same-major selection.""" @@ -213,6 +275,10 @@ def test_parses_only_exact_stable_versions(self) -> None: ) for spelling in ( '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', @@ -363,6 +429,66 @@ def test_reads_every_expected_declaration_once(self) -> None: self.assertEqual(OLD_DIGEST, pins[0].current) self.assertEqual(set(CURRENT), {pin.name for pin in pins[1:]}) + def test_real_dockerfile_declarations_match_independent_contract( + self, + ) -> None: + path = Path(__file__).resolve().parents[2] / 'Dockerfile' + content = path.read_text(encoding='utf-8') + declaration = re.compile( + r'(?m)^[ \t]*(?:(?:ARG|ENV)[ \t]+)?' + r'((?:BASE_IMAGE|PHP_[A-Z0-9_]+_VERSION))[ \t]*=' + ) + + self.assertEqual( + EXPECTED_DOCKERFILE_DECLARATIONS, + { + match.group(1) + for match in declaration.finditer(content) + }, + ) + pins = dependencies.read_pins(content) + self.assertEqual( + {dependencies.BASE_NAME, *CURRENT}, + {pin.name for pin in pins}, + ) + self.assertRegex( + pins[0].current, + r'\Asha256:[0-9a-f]{64}\Z', + ) + + def test_rejects_unknown_extension_declaration(self) -> None: + multiline = dockerfile().replace( + 'ENV \\\n', + 'ENV \\\n PHP_UNKNOWN_VERSION="1.2.3" \\\n', + 1, + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Unknown PHP extension version declaration: ' + 'PHP_UNKNOWN_VERSION', + ): + dependencies.read_pins(multiline) + + same_line = ( + dockerfile() + + '\nENV PHP_second_VERSION="1.2.3" ' + + 'PHP_THIRD_VERSION="2.3.4"\n' + ) + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Unknown PHP extension version declarations: ' + 'PHP_THIRD_VERSION, PHP_second_VERSION', + ): + dependencies.read_pins(same_line) + + argument = dockerfile() + '\nARG PHP_ARGUMENT_VERSION\n' + with self.assertRaisesRegex( + dependencies.UpdateError, + 'Unknown PHP extension version declaration: ' + 'PHP_ARGUMENT_VERSION', + ): + dependencies.read_pins(argument) + def test_rejects_missing_declaration(self) -> None: content = dockerfile().replace( f' PHP_REDIS_VERSION="{CURRENT["redis"]}" \\\n', From 1aaa07fd88fe1555e88797271d516d7b0c1dbd2e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 15:42:32 +1200 Subject: [PATCH 07/19] (fix): harden dependency release automation --- .github/scripts/automation.py | 361 ----------- .github/scripts/automation/__init__.py | 71 +++ .../automation/approval_missing_error.py | 5 + .../scripts/automation/automation_error.py | 2 + .github/scripts/automation/candidate.py | 11 + .github/scripts/automation/deadline.py | 38 ++ .../scripts/automation/head_changed_error.py | 5 + .github/scripts/automation/merge.py | 26 + .github/scripts/automation/merge_result.py | 32 + .github/scripts/automation/pull_request.py | 44 ++ .../pull_request_unavailable_error.py | 5 + .github/scripts/automation/recovery.py | 120 ++++ .github/scripts/automation/recovery_error.py | 5 + .github/scripts/automation/release.py | 23 + .github/scripts/automation/review_decision.py | 9 + .github/scripts/automation/run.py | 96 +++ .github/scripts/automation/tag.py | 23 + .../automation/target_mismatch_error.py | 5 + .github/scripts/automation/version.py | 115 ++++ .../automation/version_invalid_error.py | 5 + .../automation/version_missing_error.py | 5 + .github/scripts/automation/workflow_state.py | 12 + .github/scripts/orchestrator.py | 601 ++++++++++++++++++ .github/scripts/test_automation.py | 204 ++++++ .github/scripts/test_orchestrator.py | 97 +++ .github/workflows/dependencies.yml | 307 +++------ 26 files changed, 1638 insertions(+), 589 deletions(-) delete mode 100644 .github/scripts/automation.py create mode 100644 .github/scripts/automation/__init__.py create mode 100644 .github/scripts/automation/approval_missing_error.py create mode 100644 .github/scripts/automation/automation_error.py create mode 100644 .github/scripts/automation/candidate.py create mode 100644 .github/scripts/automation/deadline.py create mode 100644 .github/scripts/automation/head_changed_error.py create mode 100644 .github/scripts/automation/merge.py create mode 100644 .github/scripts/automation/merge_result.py create mode 100644 .github/scripts/automation/pull_request.py create mode 100644 .github/scripts/automation/pull_request_unavailable_error.py create mode 100644 .github/scripts/automation/recovery.py create mode 100644 .github/scripts/automation/recovery_error.py create mode 100644 .github/scripts/automation/release.py create mode 100644 .github/scripts/automation/review_decision.py create mode 100644 .github/scripts/automation/run.py create mode 100644 .github/scripts/automation/tag.py create mode 100644 .github/scripts/automation/target_mismatch_error.py create mode 100644 .github/scripts/automation/version.py create mode 100644 .github/scripts/automation/version_invalid_error.py create mode 100644 .github/scripts/automation/version_missing_error.py create mode 100644 .github/scripts/automation/workflow_state.py create mode 100644 .github/scripts/orchestrator.py create mode 100644 .github/scripts/test_orchestrator.py diff --git a/.github/scripts/automation.py b/.github/scripts/automation.py deleted file mode 100644 index 735298c..0000000 --- a/.github/scripts/automation.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Pure orchestration rules for dependency updates and patch releases.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from datetime import datetime, timedelta -from enum import Enum -from typing import Iterable, Sequence - - -_VERSION = re.compile( - r'(?P0|[1-9][0-9]*)\.' - r'(?P0|[1-9][0-9]*)\.' - r'(?P0|[1-9][0-9]*)' -) - - -class AutomationError(RuntimeError): - """Base error for a failed orchestration invariant.""" - - -class VersionMissingError(AutomationError): - """Raised when no stable version tag exists.""" - - -class VersionInvalidError(AutomationError): - """Raised when a required value is not a stable version.""" - - -class HeadChangedError(AutomationError): - """Raised when a pull request no longer points to the approved head.""" - - -class ApprovalMissingError(AutomationError): - """Raised when a pull request does not currently have approval.""" - - -class PullRequestUnavailableError(AutomationError): - """Raised when a pull request cannot currently be merged.""" - - -class TargetMismatchError(AutomationError): - """Raised when a tag or release points at an unexpected commit.""" - - -@dataclass(frozen=True, order=True) -class Version: - """An unprefixed stable MAJOR.MINOR.PATCH version.""" - - major: int - minor: int - patch: int - - @classmethod - def parse(cls, value: str) -> Version | None: - """Parse a stable version, returning None for unsupported tag names.""" - match = _VERSION.fullmatch(value) - if match is None: - return None - - try: - return cls( - major=int(match.group('major')), - minor=int(match.group('minor')), - patch=int(match.group('patch')), - ) - except ValueError: - return None - - def next_patch(self) -> Version: - """Return the immediately following patch version.""" - return Version(self.major, self.minor, self.patch + 1) - - def __str__(self) -> str: - return f'{self.major}.{self.minor}.{self.patch}' - - -def stable_versions(tags: Iterable[str]) -> tuple[Version, ...]: - """Return unique stable versions in semantic order.""" - versions = { - version - for tag in tags - if (version := Version.parse(tag)) is not None - } - return tuple(sorted(versions)) - - -def latest_version(tags: Iterable[str]) -> Version: - """Return the semantic maximum across all supplied remote tags.""" - versions = stable_versions(tags) - if not versions: - raise VersionMissingError('No stable remote version tag exists') - return versions[-1] - - -def next_patch(tags: Iterable[str]) -> Version: - """Compute the next patch from the semantic maximum remote tag.""" - return latest_version(tags).next_patch() - - -def newer_unreleased_tag( - tags: Iterable[str], - releases: Iterable[str], -) -> Version | None: - """Find the newest remote tag newer than every published release.""" - tagged = stable_versions(tags) - if not tagged: - return None - - published = stable_versions(releases) - threshold = published[-1] if published else None - candidates = [ - version - for version in tagged - if version not in published - and (threshold is None or version > threshold) - ] - return max(candidates, default=None) - - -def release_candidate( - tags: Iterable[str], - releases: Iterable[str], -) -> Version: - """Resume an unreleased newer tag or compute a fresh patch version.""" - tags = tuple(tags) - unreleased = newer_unreleased_tag(tags, releases) - return unreleased if unreleased is not None else next_patch(tags) - - -def patch_after_collision( - tags: Iterable[str], - collision: str, -) -> Version: - """Recompute a patch after refreshing tags following a create collision.""" - collided = Version.parse(collision) - if collided is None: - raise VersionInvalidError( - f'Collision tag {collision!r} is not a stable version' - ) - return next_patch((*tags, str(collided))) - - -def _require_aware(value: datetime, name: str) -> None: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError(f'{name} must include a timezone') - - -@dataclass(frozen=True) -class Deadline: - """An injected absolute deadline used without sleeping.""" - - at: datetime - - def __post_init__(self) -> None: - _require_aware(self.at, 'Deadline') - - @classmethod - def after(cls, now: datetime, timeout: timedelta) -> Deadline: - """Create a deadline relative to injected current time.""" - _require_aware(now, 'Current time') - if timeout <= timedelta(): - raise ValueError('Timeout must be positive') - return cls(now + timeout) - - def expired(self, now: datetime) -> bool: - """Return whether the deadline has been reached.""" - _require_aware(now, 'Current time') - return now >= self.at - - def remaining(self, now: datetime) -> timedelta: - """Return remaining time, clamped at zero.""" - _require_aware(now, 'Current time') - return max(self.at - now, timedelta()) - - -@dataclass(frozen=True) -class Run: - """Normalized workflow run state supplied by a GitHub adapter.""" - - identifier: int - workflow: str - event: str - head: str - branch: str - created: datetime - attempt: int - status: str - conclusion: str | None - - def __post_init__(self) -> None: - _require_aware(self.created, 'Workflow run creation time') - if self.attempt < 1: - raise ValueError('Workflow run attempt must be positive') - - -class WorkflowState(Enum): - """A closed set of states used by polling orchestration.""" - - MISSING = 'missing' - PENDING = 'pending' - SUCCEEDED = 'succeeded' - FAILED = 'failed' - CANCELLED = 'cancelled' - TIMED_OUT = 'timed_out' - - -def select_run( - runs: Sequence[Run], - *, - workflow: str, - event: str, - head: str, - branch: str, - created: datetime, -) -> Run | None: - """Select the newest exact run and its newest rerun attempt.""" - _require_aware(created, 'Workflow run boundary') - matches = [ - run - for run in runs - if run.workflow == workflow - and run.event == event - and run.head == head - and run.branch == branch - and run.created >= created - ] - return max( - matches, - key=lambda run: (run.created, run.identifier, run.attempt), - default=None, - ) - - -def workflow_state( - runs: Sequence[Run], - *, - workflow: str, - event: str, - head: str, - branch: str, - created: datetime, - deadline: Deadline, - now: datetime, -) -> WorkflowState: - """Evaluate one exact workflow run without polling or sleeping.""" - run = select_run( - runs, - workflow=workflow, - event=event, - head=head, - branch=branch, - created=created, - ) - if run is None: - if deadline.expired(now): - return WorkflowState.TIMED_OUT - return WorkflowState.MISSING - - if run.status != 'completed': - if deadline.expired(now): - return WorkflowState.TIMED_OUT - return WorkflowState.PENDING - - if run.conclusion == 'success': - return WorkflowState.SUCCEEDED - if run.conclusion == 'cancelled': - return WorkflowState.CANCELLED - if run.conclusion == 'timed_out': - return WorkflowState.TIMED_OUT - return WorkflowState.FAILED - - -class ReviewDecision(Enum): - """Normalized current pull request review decision.""" - - APPROVED = 'approved' - CHANGES_REQUESTED = 'changes_requested' - REVIEW_REQUIRED = 'review_required' - - -@dataclass(frozen=True) -class PullRequest: - """Current pull request state supplied immediately before merging.""" - - number: int - head: str - state: str - review: ReviewDecision - - -def validate_pull_request( - pull_request: PullRequest, - expected_head: str, -) -> None: - """Require the unchanged head and a current approval before merging.""" - if pull_request.head != expected_head: - raise HeadChangedError( - f'Pull request #{pull_request.number} head changed from ' - f'{expected_head} to {pull_request.head}' - ) - if pull_request.state != 'open': - raise PullRequestUnavailableError( - f'Pull request #{pull_request.number} is {pull_request.state}' - ) - if pull_request.review is not ReviewDecision.APPROVED: - raise ApprovalMissingError( - f'Pull request #{pull_request.number} is not currently approved' - ) - - -@dataclass(frozen=True) -class Tag: - """A remote tag resolved to its target commit.""" - - name: str - target: str - - -@dataclass(frozen=True) -class Release: - """A published release resolved to its tag target commit.""" - - tag: str - target: str - - -def validate_tag_target( - tag: Tag, - *, - expected_name: str, - expected_target: str, -) -> None: - """Require a tag with the expected name and exact target commit.""" - if tag.name != expected_name: - raise TargetMismatchError( - f'Expected tag {expected_name}, found {tag.name}' - ) - if tag.target != expected_target: - raise TargetMismatchError( - f'Tag {tag.name} targets {tag.target}, expected {expected_target}' - ) - - -def validate_release_target( - release: Release, - *, - expected_tag: str, - expected_target: str, -) -> None: - """Require a release for the expected tag and exact target commit.""" - if release.tag != expected_tag: - raise TargetMismatchError( - f'Expected release for {expected_tag}, found {release.tag}' - ) - if release.target != expected_target: - raise TargetMismatchError( - f'Release {release.tag} targets {release.target}, ' - f'expected {expected_target}' - ) diff --git a/.github/scripts/automation/__init__.py b/.github/scripts/automation/__init__.py new file mode 100644 index 0000000..4000fb5 --- /dev/null +++ b/.github/scripts/automation/__init__.py @@ -0,0 +1,71 @@ +"""Typed orchestration rules for dependency updates and patch releases.""" + +from automation.approval_missing_error import ApprovalMissingError +from automation.automation_error import AutomationError +from automation.candidate import Candidate +from automation.deadline import Deadline +from automation.head_changed_error import HeadChangedError +from automation.merge import Merge +from automation.merge_result import MergeResult +from automation.pull_request import PullRequest +from automation.pull_request_unavailable_error import ( + PullRequestUnavailableError, +) +from automation.recovery import Recovery +from automation.recovery_error import RecoveryError +from automation.release import Release +from automation.review_decision import ReviewDecision +from automation.run import Run +from automation.tag import Tag +from automation.target_mismatch_error import TargetMismatchError +from automation.version import Version +from automation.version_invalid_error import VersionInvalidError +from automation.version_missing_error import VersionMissingError +from automation.workflow_state import WorkflowState + + +latest_version = Version.latest +newer_unreleased_tag = Version.unreleased +next_patch = Version.next +patch_after_collision = Version.after_collision +release_candidate = Version.candidate +select_run = Run.select +stable_versions = Version.stable +validate_pull_request = PullRequest.validate +validate_release_target = Release.validate +validate_tag_target = Tag.validate +workflow_state = Run.workflow_state + +__all__ = ( + 'ApprovalMissingError', + 'AutomationError', + 'Candidate', + 'Deadline', + 'HeadChangedError', + 'Merge', + 'MergeResult', + 'PullRequest', + 'PullRequestUnavailableError', + 'Recovery', + 'RecoveryError', + 'Release', + 'ReviewDecision', + 'Run', + 'Tag', + 'TargetMismatchError', + 'Version', + 'VersionInvalidError', + 'VersionMissingError', + 'WorkflowState', + 'latest_version', + 'newer_unreleased_tag', + 'next_patch', + 'patch_after_collision', + 'release_candidate', + 'select_run', + 'stable_versions', + 'validate_pull_request', + 'validate_release_target', + 'validate_tag_target', + 'workflow_state', +) diff --git a/.github/scripts/automation/approval_missing_error.py b/.github/scripts/automation/approval_missing_error.py new file mode 100644 index 0000000..a2b9427 --- /dev/null +++ b/.github/scripts/automation/approval_missing_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class ApprovalMissingError(AutomationError): + """Raised when a pull request does not currently have approval.""" diff --git a/.github/scripts/automation/automation_error.py b/.github/scripts/automation/automation_error.py new file mode 100644 index 0000000..13744c1 --- /dev/null +++ b/.github/scripts/automation/automation_error.py @@ -0,0 +1,2 @@ +class AutomationError(RuntimeError): + """Base error for a failed orchestration invariant.""" diff --git a/.github/scripts/automation/candidate.py b/.github/scripts/automation/candidate.py new file mode 100644 index 0000000..f72281c --- /dev/null +++ b/.github/scripts/automation/candidate.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Candidate: + """A uniquely recoverable dependency release.""" + + tag: str + target: str + pull: int + draft: int | None diff --git a/.github/scripts/automation/deadline.py b/.github/scripts/automation/deadline.py new file mode 100644 index 0000000..76ef357 --- /dev/null +++ b/.github/scripts/automation/deadline.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta + + +@dataclass(frozen=True) +class Deadline: + """An injected absolute deadline used without sleeping.""" + + at: datetime + + def __post_init__(self) -> None: + self.require_aware(self.at, 'Deadline') + + @staticmethod + def require_aware(value: datetime, name: str) -> None: + """Require an offset-aware timestamp.""" + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f'{name} must include a timezone') + + @classmethod + def after(cls, now: datetime, timeout: timedelta) -> Deadline: + """Create a deadline relative to injected current time.""" + cls.require_aware(now, 'Current time') + if timeout <= timedelta(): + raise ValueError('Timeout must be positive') + return cls(now + timeout) + + def expired(self, now: datetime) -> bool: + """Return whether the deadline has been reached.""" + self.require_aware(now, 'Current time') + return now >= self.at + + def remaining(self, now: datetime) -> timedelta: + """Return remaining time, clamped at zero.""" + self.require_aware(now, 'Current time') + return max(self.at - now, timedelta()) diff --git a/.github/scripts/automation/head_changed_error.py b/.github/scripts/automation/head_changed_error.py new file mode 100644 index 0000000..77f6448 --- /dev/null +++ b/.github/scripts/automation/head_changed_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class HeadChangedError(AutomationError): + """Raised when a pull request no longer points to an approved ref.""" diff --git a/.github/scripts/automation/merge.py b/.github/scripts/automation/merge.py new file mode 100644 index 0000000..ed2fa41 --- /dev/null +++ b/.github/scripts/automation/merge.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Merge: + """Repository evidence for a merged dependency automation pull request.""" + + marker = '' + + number: int + target: str + base: str + branch: str + body: str + files: tuple[str, ...] + state: str + + def is_automation(self) -> bool: + """Return whether all immutable automation provenance facts match.""" + return ( + self.state == 'merged' + and self.base == 'main' + and self.branch.startswith('automation/dependencies-') + and self.marker in self.body + and self.files == ('Dockerfile',) + ) diff --git a/.github/scripts/automation/merge_result.py b/.github/scripts/automation/merge_result.py new file mode 100644 index 0000000..527773e --- /dev/null +++ b/.github/scripts/automation/merge_result.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +from automation.head_changed_error import HeadChangedError +from automation.pull_request_unavailable_error import ( + PullRequestUnavailableError, +) + + +@dataclass(frozen=True) +class MergeResult: + """Final pull request state returned after a successful merge command.""" + + head: str + state: str + commit: str | None + + def validate(self, expected_head: str) -> str: + """Require a merged result for the exact tested head.""" + if self.head != expected_head: + raise HeadChangedError( + f'Merged pull request head changed from {expected_head} ' + f'to {self.head}' + ) + if self.state != 'merged': + raise PullRequestUnavailableError( + f'Pull request merge ended in state {self.state}' + ) + if self.commit is None: + raise PullRequestUnavailableError( + 'Pull request merge produced no commit' + ) + return self.commit diff --git a/.github/scripts/automation/pull_request.py b/.github/scripts/automation/pull_request.py new file mode 100644 index 0000000..ae8a5d6 --- /dev/null +++ b/.github/scripts/automation/pull_request.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass + +from automation.approval_missing_error import ApprovalMissingError +from automation.head_changed_error import HeadChangedError +from automation.pull_request_unavailable_error import ( + PullRequestUnavailableError, +) +from automation.review_decision import ReviewDecision + + +@dataclass(frozen=True) +class PullRequest: + """Current pull request state supplied immediately before merging.""" + + number: int + head: str + state: str + review: ReviewDecision + base: str = '' + + def validate( + self, + expected_head: str, + expected_base: str | None = None, + ) -> None: + """Require unchanged tested refs and a current approval.""" + if self.head != expected_head: + raise HeadChangedError( + f'Pull request #{self.number} head changed from ' + f'{expected_head} to {self.head}' + ) + if expected_base is not None and self.base != expected_base: + raise HeadChangedError( + f'Pull request #{self.number} base changed from ' + f'{expected_base} to {self.base}' + ) + if self.state != 'open': + raise PullRequestUnavailableError( + f'Pull request #{self.number} is {self.state}' + ) + if self.review is not ReviewDecision.APPROVED: + raise ApprovalMissingError( + f'Pull request #{self.number} is not currently approved' + ) diff --git a/.github/scripts/automation/pull_request_unavailable_error.py b/.github/scripts/automation/pull_request_unavailable_error.py new file mode 100644 index 0000000..b471121 --- /dev/null +++ b/.github/scripts/automation/pull_request_unavailable_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class PullRequestUnavailableError(AutomationError): + """Raised when a pull request cannot currently be merged.""" diff --git a/.github/scripts/automation/recovery.py b/.github/scripts/automation/recovery.py new file mode 100644 index 0000000..494f12c --- /dev/null +++ b/.github/scripts/automation/recovery.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +from automation.candidate import Candidate +from automation.merge import Merge +from automation.recovery_error import RecoveryError +from automation.tag import Tag +from automation.version import Version + + +@dataclass(frozen=True) +class Recovery: + """Select safe release recovery state from repository facts.""" + + marker = '' + + identifier: int + tag: str + target: str + pull: int + draft: bool + prerelease: bool + body: str + + def matches(self, merge: Merge) -> bool: + """Return whether this draft records the exact merge provenance.""" + return ( + self.draft + and not self.prerelease + and self.marker in self.body + and f'' in self.body + and f'' in self.body + and self.pull in {0, merge.number} + and self.target == merge.target + ) + + @classmethod + def select( + cls, + tags: Sequence[Tag], + releases: Sequence[Recovery], + merges: Sequence[Merge], + ) -> Candidate | None: + """Select one qualified unpublished tag or fail closed.""" + published = { + release.tag + for release in releases + if not release.draft + } + stable_published = Version.stable(published) + threshold = stable_published[-1] if stable_published else None + candidates: list[Candidate] = [] + + for tag in tags: + version = Version.parse(tag.name) + if ( + version is None + or tag.name in published + or (threshold is not None and version <= threshold) + ): + continue + + evidence = [ + merge + for merge in merges + if merge.target == tag.target and merge.is_automation() + ] + if len(evidence) != 1: + continue + + merge = evidence[0] + drafts = [ + release + for release in releases + if release.tag == tag.name and release.matches(merge) + ] + if len(drafts) > 1: + raise RecoveryError( + f'Multiple automation drafts exist for {tag.name}' + ) + candidates.append( + Candidate( + tag=tag.name, + target=tag.target, + pull=merge.number, + draft=drafts[0].identifier if drafts else None, + ) + ) + + unique = { + (candidate.tag, candidate.target, candidate.pull, candidate.draft): + candidate + for candidate in candidates + } + if len(unique) > 1: + names = ', '.join( + sorted(candidate.tag for candidate in unique.values()) + ) + raise RecoveryError( + f'Multiple dependency releases are recoverable: {names}' + ) + candidate = next(iter(unique.values()), None) + marked = [ + release + for release in releases + if release.draft and cls.marker in release.body + ] + if marked and ( + candidate is None + or any( + release.identifier != candidate.draft + for release in marked + ) + ): + raise RecoveryError( + 'Unsafe dependency automation draft state exists' + ) + return candidate diff --git a/.github/scripts/automation/recovery_error.py b/.github/scripts/automation/recovery_error.py new file mode 100644 index 0000000..d667866 --- /dev/null +++ b/.github/scripts/automation/recovery_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class RecoveryError(AutomationError): + """Raised when release recovery state is unsafe or ambiguous.""" diff --git a/.github/scripts/automation/release.py b/.github/scripts/automation/release.py new file mode 100644 index 0000000..407f4a2 --- /dev/null +++ b/.github/scripts/automation/release.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + +from automation.target_mismatch_error import TargetMismatchError + + +@dataclass(frozen=True) +class Release: + """A release and the resolved target of its associated tag.""" + + tag: str + target: str + + def validate(self, *, expected_tag: str, expected_target: str) -> None: + """Require the expected tag and exact target commit.""" + if self.tag != expected_tag: + raise TargetMismatchError( + f'Expected release for {expected_tag}, found {self.tag}' + ) + if self.target != expected_target: + raise TargetMismatchError( + f'Release {self.tag} targets {self.target}, ' + f'expected {expected_target}' + ) diff --git a/.github/scripts/automation/review_decision.py b/.github/scripts/automation/review_decision.py new file mode 100644 index 0000000..42a5c75 --- /dev/null +++ b/.github/scripts/automation/review_decision.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class ReviewDecision(Enum): + """Normalized current pull request review decision.""" + + APPROVED = 'approved' + CHANGES_REQUESTED = 'changes_requested' + REVIEW_REQUIRED = 'review_required' diff --git a/.github/scripts/automation/run.py b/.github/scripts/automation/run.py new file mode 100644 index 0000000..aed7c97 --- /dev/null +++ b/.github/scripts/automation/run.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Sequence + +from automation.deadline import Deadline +from automation.workflow_state import WorkflowState + + +@dataclass(frozen=True) +class Run: + """Normalized workflow run state supplied by a GitHub adapter.""" + + identifier: int + workflow: str + event: str + head: str + branch: str + created: datetime + attempt: int + status: str + conclusion: str | None + + def __post_init__(self) -> None: + Deadline.require_aware(self.created, 'Workflow run creation time') + if self.attempt < 1: + raise ValueError('Workflow run attempt must be positive') + + @classmethod + def select( + cls, + runs: Sequence[Run], + *, + workflow: str, + event: str, + head: str, + branch: str, + created: datetime, + ) -> Run | None: + """Select the newest exact run and its newest rerun attempt.""" + Deadline.require_aware(created, 'Workflow run boundary') + matches = [ + run + for run in runs + if run.workflow == workflow + and run.event == event + and run.head == head + and run.branch == branch + and run.created >= created + ] + return max( + matches, + key=lambda run: (run.created, run.identifier, run.attempt), + default=None, + ) + + @classmethod + def workflow_state( + cls, + runs: Sequence[Run], + *, + workflow: str, + event: str, + head: str, + branch: str, + created: datetime, + deadline: Deadline, + now: datetime, + ) -> WorkflowState: + """Evaluate one exact workflow run without polling or sleeping.""" + run = cls.select( + runs, + workflow=workflow, + event=event, + head=head, + branch=branch, + created=created, + ) + if run is None: + if deadline.expired(now): + return WorkflowState.TIMED_OUT + return WorkflowState.MISSING + + if run.status != 'completed': + if deadline.expired(now): + return WorkflowState.TIMED_OUT + return WorkflowState.PENDING + + if run.conclusion == 'success': + return WorkflowState.SUCCEEDED + if run.conclusion == 'cancelled': + return WorkflowState.CANCELLED + if run.conclusion == 'timed_out': + return WorkflowState.TIMED_OUT + return WorkflowState.FAILED diff --git a/.github/scripts/automation/tag.py b/.github/scripts/automation/tag.py new file mode 100644 index 0000000..bd0467a --- /dev/null +++ b/.github/scripts/automation/tag.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + +from automation.target_mismatch_error import TargetMismatchError + + +@dataclass(frozen=True) +class Tag: + """A remote tag resolved to its target commit.""" + + name: str + target: str + + def validate(self, *, expected_name: str, expected_target: str) -> None: + """Require the expected name and exact target commit.""" + if self.name != expected_name: + raise TargetMismatchError( + f'Expected tag {expected_name}, found {self.name}' + ) + if self.target != expected_target: + raise TargetMismatchError( + f'Tag {self.name} targets {self.target}, ' + f'expected {expected_target}' + ) diff --git a/.github/scripts/automation/target_mismatch_error.py b/.github/scripts/automation/target_mismatch_error.py new file mode 100644 index 0000000..962ba7b --- /dev/null +++ b/.github/scripts/automation/target_mismatch_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class TargetMismatchError(AutomationError): + """Raised when a tag or release points at an unexpected commit.""" diff --git a/.github/scripts/automation/version.py b/.github/scripts/automation/version.py new file mode 100644 index 0000000..dfe1256 --- /dev/null +++ b/.github/scripts/automation/version.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import ClassVar, Iterable, Pattern + +from automation.version_invalid_error import VersionInvalidError +from automation.version_missing_error import VersionMissingError + + +@dataclass(frozen=True, order=True) +class Version: + """An unprefixed stable MAJOR.MINOR.PATCH version.""" + + pattern: ClassVar[Pattern[str]] = re.compile( + r'(?P0|[1-9][0-9]*)\.' + r'(?P0|[1-9][0-9]*)\.' + r'(?P0|[1-9][0-9]*)' + ) + + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> Version | None: + """Parse a stable version, returning None for unsupported tag names.""" + match = cls.pattern.fullmatch(value) + if match is None: + return None + + try: + return cls( + major=int(match.group('major')), + minor=int(match.group('minor')), + patch=int(match.group('patch')), + ) + except ValueError: + return None + + @classmethod + def stable(cls, tags: Iterable[str]) -> tuple[Version, ...]: + """Return unique stable versions in semantic order.""" + versions = { + version + for tag in tags + if (version := cls.parse(tag)) is not None + } + return tuple(sorted(versions)) + + @classmethod + def latest(cls, tags: Iterable[str]) -> Version: + """Return the semantic maximum across all supplied remote tags.""" + versions = cls.stable(tags) + if not versions: + raise VersionMissingError('No stable remote version tag exists') + return versions[-1] + + @classmethod + def next(cls, tags: Iterable[str]) -> Version: + """Compute the next patch from the semantic maximum remote tag.""" + return cls.latest(tags).next_patch() + + @classmethod + def unreleased( + cls, + tags: Iterable[str], + releases: Iterable[str], + ) -> Version | None: + """Find the newest remote tag newer than every published release.""" + tagged = cls.stable(tags) + if not tagged: + return None + + published = cls.stable(releases) + threshold = published[-1] if published else None + candidates = [ + version + for version in tagged + if version not in published + and (threshold is None or version > threshold) + ] + return max(candidates, default=None) + + @classmethod + def candidate( + cls, + tags: Iterable[str], + releases: Iterable[str], + ) -> Version: + """Resume an unreleased newer tag or compute a fresh patch version.""" + tags = tuple(tags) + unreleased = cls.unreleased(tags, releases) + return unreleased if unreleased is not None else cls.next(tags) + + @classmethod + def after_collision( + cls, + tags: Iterable[str], + collision: str, + ) -> Version: + """Recompute a patch after refreshing tags following a collision.""" + collided = cls.parse(collision) + if collided is None: + raise VersionInvalidError( + f'Collision tag {collision!r} is not a stable version' + ) + return cls.next((*tags, str(collided))) + + def next_patch(self) -> Version: + """Return the immediately following patch version.""" + return Version(self.major, self.minor, self.patch + 1) + + def __str__(self) -> str: + return f'{self.major}.{self.minor}.{self.patch}' diff --git a/.github/scripts/automation/version_invalid_error.py b/.github/scripts/automation/version_invalid_error.py new file mode 100644 index 0000000..2ca6d9b --- /dev/null +++ b/.github/scripts/automation/version_invalid_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class VersionInvalidError(AutomationError): + """Raised when a required value is not a stable version.""" diff --git a/.github/scripts/automation/version_missing_error.py b/.github/scripts/automation/version_missing_error.py new file mode 100644 index 0000000..2eacbc0 --- /dev/null +++ b/.github/scripts/automation/version_missing_error.py @@ -0,0 +1,5 @@ +from automation.automation_error import AutomationError + + +class VersionMissingError(AutomationError): + """Raised when no stable version tag exists.""" diff --git a/.github/scripts/automation/workflow_state.py b/.github/scripts/automation/workflow_state.py new file mode 100644 index 0000000..b0ccf65 --- /dev/null +++ b/.github/scripts/automation/workflow_state.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class WorkflowState(Enum): + """A closed set of states used by polling orchestration.""" + + MISSING = 'missing' + PENDING = 'pending' + SUCCEEDED = 'succeeded' + FAILED = 'failed' + CANCELLED = 'cancelled' + TIMED_OUT = 'timed_out' diff --git a/.github/scripts/orchestrator.py b/.github/scripts/orchestrator.py new file mode 100644 index 0000000..7112080 --- /dev/null +++ b/.github/scripts/orchestrator.py @@ -0,0 +1,601 @@ +"""GitHub adapter for the weekly dependency release workflow.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Sequence + + +sys.path.insert(0, str(Path(__file__).parent)) + +from automation import ( # noqa: E402 + Deadline, + Merge, + Recovery, + Release, + Run, + Tag, + Version, + WorkflowState, + validate_release_target, + validate_tag_target, + workflow_state, +) + + +class Orchestrator: + """Run GitHub operations behind exact, testable domain invariants.""" + + def __init__(self) -> None: + self.repository = os.environ['GITHUB_REPOSITORY'] + self.version = os.environ['GITHUB_API_VERSION'] + self.header = f'X-GitHub-Api-Version: {self.version}' + + def execute(self, arguments: Sequence[str]) -> None: + """Dispatch one workflow operation.""" + if not arguments: + raise ValueError('An orchestration operation is required') + + operation, *values = arguments + if operation == 'recover' and not values: + self.recover() + return + if operation == 'prepare' and len(values) == 4: + tag, target, pull, draft = values + self.prepare( + tag=tag or None, + target=target, + pull=int(pull), + draft=int(draft) if draft else None, + ) + return + if operation == 'wait' and len(values) == 2: + self.wait(tag=values[0], target=values[1]) + return + if operation == 'publish' and len(values) == 4: + self.publish( + tag=values[0], + target=values[1], + pull=int(values[2]), + draft=int(values[3]), + ) + return + raise ValueError(f'Invalid {operation!r} arguments') + + def _run( + self, + arguments: Sequence[str], + *, + check: bool = True, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + arguments, + check=check, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def _api( + self, + method: str, + endpoint: str, + fields: Sequence[tuple[str, str]] = (), + *, + check: bool = True, + ) -> subprocess.CompletedProcess[str]: + arguments = [ + 'gh', + 'api', + '-X', + method, + endpoint, + '-H', + self.header, + ] + for kind, value in fields: + arguments.extend((kind, value)) + return self._run(arguments, check=check) + + def _pages(self, endpoint: str) -> list[dict[str, Any]]: + result = self._run( + ( + 'gh', + 'api', + '--paginate', + '--slurp', + '-X', + 'GET', + endpoint, + '-H', + self.header, + '-f', + 'per_page=100', + ) + ) + pages = json.loads(result.stdout) + return [ + item + for page in pages + for item in page + if isinstance(item, dict) + ] + + def _write(self, values: dict[str, str]) -> None: + with open( + os.environ['GITHUB_OUTPUT'], + 'a', + encoding='utf-8', + ) as output: + for name, value in values.items(): + print(f'{name}={value}', file=output) + + def _tags(self) -> tuple[Tag, ...]: + prefix = 'refs/tags/' + return tuple( + Tag( + name=str(item.get('ref', '')).removeprefix(prefix), + target=str(item['object']['sha']), + ) + for item in self._pages( + f'repos/{self.repository}/git/matching-refs/tags/' + ) + if str(item.get('ref', '')).startswith(prefix) + and isinstance(item.get('object'), dict) + and item['object'].get('type') == 'commit' + ) + + def _releases(self) -> tuple[Recovery, ...]: + return tuple( + Recovery( + identifier=int(item['id']), + tag=str(item['tag_name']), + target=str(item['target_commitish']), + pull=0, + draft=bool(item['draft']), + prerelease=bool(item['prerelease']), + body=str(item.get('body') or ''), + ) + for item in self._pages(f'repos/{self.repository}/releases') + ) + + def _merges(self, tags: Sequence[Tag]) -> tuple[Merge, ...]: + merges: list[Merge] = [] + for target in {tag.target for tag in tags}: + pulls = self._pages( + f'repos/{self.repository}/commits/{target}/pulls' + ) + for pull in pulls: + number = int(pull['number']) + files = tuple( + str(item['filename']) + for item in self._pages( + f'repos/{self.repository}/pulls/{number}/files' + ) + ) + head = pull.get('head') + base = pull.get('base') + merged = ( + pull.get('merged_at') is not None + and pull.get('merge_commit_sha') == target + ) + merges.append( + Merge( + number=number, + target=target, + base=( + str(base.get('ref', '')) + if isinstance(base, dict) + else '' + ), + branch=( + str(head.get('ref', '')) + if isinstance(head, dict) + else '' + ), + body=str(pull.get('body') or ''), + files=files, + state='merged' if merged else str(pull.get('state')), + ) + ) + return tuple(merges) + + def _read_tag(self, name: str) -> Tag | None: + result = self._api( + 'GET', + f'repos/{self.repository}/git/ref/tags/{name}', + check=False, + ) + if result.returncode != 0: + return None + payload = json.loads(result.stdout) + target = payload.get('object') + if not isinstance(target, dict) or target.get('type') != 'commit': + raise RuntimeError(f'Tag {name} is not lightweight') + return Tag( + name=str(payload.get('ref', '')).removeprefix('refs/tags/'), + target=str(target.get('sha')), + ) + + def _read_release(self, identifier: int) -> dict[str, Any]: + result = self._api( + 'GET', + f'repos/{self.repository}/releases/{identifier}', + ) + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise RuntimeError(f'Release {identifier} is invalid') + return payload + + def _draft_body(self, target: str, pull: int) -> str: + return ( + '\n' + f'\n' + f'\n\n' + 'Automated weekly dependency release.' + ) + + def _validate_draft( + self, + payload: dict[str, Any], + *, + tag: str, + target: str, + pull: int, + ) -> int: + draft = Recovery( + identifier=int(payload['id']), + tag=str(payload['tag_name']), + target=str(payload['target_commitish']), + pull=pull, + draft=bool(payload['draft']), + prerelease=bool(payload['prerelease']), + body=str(payload.get('body') or ''), + ) + merge = Merge( + number=pull, + target=target, + base='main', + branch='automation/dependencies-recovery', + body=Merge.marker, + files=('Dockerfile',), + state='merged', + ) + if draft.tag != tag or not draft.matches(merge): + raise RuntimeError(f'Draft release {draft.identifier} is unsafe') + return draft.identifier + + def recover(self) -> None: + """Recover only state proven to originate from this automation.""" + releases = self._releases() + published = { + release.tag + for release in releases + if not release.draft + } + threshold = Version.stable(published) + latest = threshold[-1] if threshold else None + tags = tuple( + tag + for tag in self._tags() + if ( + (version := Version.parse(tag.name)) is not None + and tag.name not in published + and (latest is None or version > latest) + ) + ) + candidate = Recovery.select( + tags, + releases, + self._merges(tags), + ) + if candidate is None: + self._write({'pending': 'false'}) + return + + self._write( + { + 'pending': 'true', + 'tag': candidate.tag, + 'head': candidate.target, + 'pull': str(candidate.pull), + 'draft': ( + str(candidate.draft) + if candidate.draft is not None + else '' + ), + } + ) + + def _create_tag(self, target: str) -> str: + available = tuple(tag.name for tag in self._tags()) + candidate = str(Version.next(available)) + while True: + result = self._api( + 'POST', + f'repos/{self.repository}/git/refs', + ( + ('-f', f'ref=refs/tags/{candidate}'), + ('-f', f'sha={target}'), + ), + check=False, + ) + if result.returncode == 0: + break + + existing = self._read_tag(candidate) + if existing is not None and existing.target == target: + break + if existing is None: + sys.stderr.write(result.stderr) + raise RuntimeError(f'Failed to create tag {candidate}') + available = tuple(tag.name for tag in self._tags()) + candidate = str( + Version.after_collision(available, candidate) + ) + + tag = self._read_tag(candidate) + if tag is None: + raise RuntimeError(f'Tag {candidate} is missing after creation') + validate_tag_target( + tag, + expected_name=candidate, + expected_target=target, + ) + return candidate + + def _create_draft(self, tag: str, target: str, pull: int) -> int: + body = self._draft_body(target, pull) + result = self._api( + 'POST', + f'repos/{self.repository}/releases', + ( + ('-f', f'tag_name={tag}'), + ('-f', f'target_commitish={target}'), + ('-f', f'name={tag}'), + ('-f', f'body={body}'), + ('-F', 'draft=true'), + ('-F', 'prerelease=false'), + ('-F', 'generate_release_notes=true'), + ), + check=False, + ) + if result.returncode == 0: + payload = json.loads(result.stdout) + return self._validate_draft( + payload, + tag=tag, + target=target, + pull=pull, + ) + + matches = [ + release + for release in self._releases() + if release.tag == tag + ] + if len(matches) != 1: + sys.stderr.write(result.stderr) + raise RuntimeError(f'Failed to create draft release {tag}') + return self._validate_draft( + self._read_release(matches[0].identifier), + tag=tag, + target=target, + pull=pull, + ) + + def prepare( + self, + *, + tag: str | None, + target: str, + pull: int, + draft: int | None, + ) -> None: + """Create or recover an exact lightweight tag and draft release.""" + name = tag if tag is not None else self._create_tag(target) + existing = self._read_tag(name) + if existing is None: + raise RuntimeError(f'Tag {name} does not exist') + validate_tag_target( + existing, + expected_name=name, + expected_target=target, + ) + + identifier = ( + self._validate_draft( + self._read_release(draft), + tag=name, + target=target, + pull=pull, + ) + if draft is not None + else self._create_draft(name, target, pull) + ) + current = self._read_tag(name) + if current is None: + raise RuntimeError(f'Tag {name} disappeared during preparation') + validate_tag_target( + current, + expected_name=name, + expected_target=target, + ) + self._write( + { + 'tag': name, + 'head': target, + 'pull': str(pull), + 'draft': str(identifier), + } + ) + + def _runs(self, tag: str, target: str) -> tuple[Run, ...]: + result = self._api( + 'GET', + ( + f'repos/{self.repository}/actions/workflows/' + 'build-and-push.yml/runs' + ), + ( + ('-f', f'branch={tag}'), + ('-f', f'head_sha={target}'), + ('-f', 'event=push'), + ('-f', 'per_page=100'), + ), + ) + payload = json.loads(result.stdout) + return tuple( + Run( + identifier=int(item['id']), + workflow=str(item['name']), + event=str(item['event']), + head=str(item['head_sha']), + branch=str(item['head_branch']), + created=datetime.fromisoformat( + str(item['created_at']).replace('Z', '+00:00') + ), + attempt=int(item['run_attempt']), + status=str(item['status']), + conclusion=( + str(item['conclusion']) + if item['conclusion'] is not None + else None + ), + ) + for item in payload['workflow_runs'] + ) + + def wait(self, *, tag: str, target: str) -> None: + """Require the exact tag-push Build and Push run to succeed.""" + boundary = datetime(1970, 1, 1, tzinfo=timezone.utc) + deadline = Deadline.after( + datetime.now(timezone.utc), + timedelta(minutes=120), + ) + previous = None + while True: + current = self._read_tag(tag) + if current is None: + raise RuntimeError(f'Tag {tag} disappeared during its build') + validate_tag_target( + current, + expected_name=tag, + expected_target=target, + ) + now = datetime.now(timezone.utc) + state = workflow_state( + self._runs(tag, target), + workflow='Build and Push', + event='push', + head=target, + branch=tag, + created=boundary, + deadline=deadline, + now=now, + ) + if state is not previous: + print(f'Build and Push tag run: {state.value}', flush=True) + previous = state + if state is WorkflowState.SUCCEEDED: + return + if state in { + WorkflowState.CANCELLED, + WorkflowState.FAILED, + WorkflowState.TIMED_OUT, + }: + raise RuntimeError( + f'Tag Build and Push did not succeed: {state.value}' + ) + remaining = deadline.remaining( + datetime.now(timezone.utc) + ).total_seconds() + time.sleep(min(20, max(remaining, 0))) + + def publish( + self, + *, + tag: str, + target: str, + pull: int, + draft: int, + ) -> None: + """Publish only an exact draft and re-draft any wrong target.""" + current = self._read_tag(tag) + if current is None: + raise RuntimeError(f'Tag {tag} disappeared before publication') + validate_tag_target( + current, + expected_name=tag, + expected_target=target, + ) + self._validate_draft( + self._read_release(draft), + tag=tag, + target=target, + pull=pull, + ) + + result = self._api( + 'PATCH', + f'repos/{self.repository}/releases/{draft}', + ( + ('-F', 'draft=false'), + ('-F', 'prerelease=false'), + ('-f', 'make_latest=true'), + ), + check=False, + ) + payload = ( + json.loads(result.stdout) + if result.returncode == 0 and result.stdout + else self._read_release(draft) + ) + final = self._read_tag(tag) + valid = ( + final is not None + and final.name == tag + and final.target == target + and payload.get('tag_name') == tag + and payload.get('draft') is False + and payload.get('prerelease') is False + ) + if not valid: + rollback = self._api( + 'PATCH', + f'repos/{self.repository}/releases/{draft}', + (('-F', 'draft=true'),), + check=False, + ) + rolled_back = self._read_release(draft) + if rolled_back.get('draft') is not True: + sys.stderr.write(rollback.stderr) + raise RuntimeError( + f'Release {tag} has an unsafe public target and ' + 'could not be returned to draft' + ) + raise RuntimeError( + f'Release {tag} target changed during publication; ' + 'the release was returned to draft' + ) + + validate_tag_target( + final, + expected_name=tag, + expected_target=target, + ) + validate_release_target( + Release(tag=str(payload['tag_name']), target=final.target), + expected_tag=tag, + expected_target=target, + ) + + +if __name__ == '__main__': + Orchestrator().execute(sys.argv[1:]) diff --git a/.github/scripts/test_automation.py b/.github/scripts/test_automation.py index b68a8cf..ed220db 100644 --- a/.github/scripts/test_automation.py +++ b/.github/scripts/test_automation.py @@ -8,9 +8,14 @@ from automation import ( # noqa: E402 ApprovalMissingError, + Candidate, Deadline, HeadChangedError, + Merge, + MergeResult, PullRequest, + Recovery, + RecoveryError, Release, ReviewDecision, Run, @@ -250,6 +255,20 @@ def test_reports_missing_run_before_deadline(self) -> None: def test_reports_successful_run(self) -> None: self.assertIs(WorkflowState.SUCCEEDED, self.state([run()])) + def test_does_not_accept_branch_run_for_tag_release(self) -> None: + state = workflow_state( + [run(branch='main')], + workflow='Build and Push', + event='push', + head='approved-head', + branch='1.4.5', + created=START, + deadline=self.deadline, + now=START, + ) + + self.assertIs(WorkflowState.MISSING, state) + def test_reports_failed_run(self) -> None: self.assertIs( WorkflowState.FAILED, @@ -308,10 +327,12 @@ def test_accepts_current_approved_head(self) -> None: PullRequest( number=75, head='approved-head', + base='tested-base', state='open', review=ReviewDecision.APPROVED, ), 'approved-head', + 'tested-base', ) def test_rejects_changed_head_even_if_currently_approved(self) -> None: @@ -320,10 +341,26 @@ def test_rejects_changed_head_even_if_currently_approved(self) -> None: PullRequest( number=75, head='changed-head', + base='tested-base', state='open', review=ReviewDecision.APPROVED, ), 'approved-head', + 'tested-base', + ) + + def test_rejects_changed_base_after_ci_succeeds(self) -> None: + with self.assertRaises(HeadChangedError): + validate_pull_request( + PullRequest( + number=75, + head='approved-head', + base='changed-base', + state='open', + review=ReviewDecision.APPROVED, + ), + 'approved-head', + 'tested-base', ) def test_rejects_missing_current_approval(self) -> None: @@ -332,13 +369,35 @@ def test_rejects_missing_current_approval(self) -> None: PullRequest( number=75, head='approved-head', + base='tested-base', state='open', review=ReviewDecision.REVIEW_REQUIRED, ), 'approved-head', + 'tested-base', ) +class MergeResultTest(unittest.TestCase): + def test_accepts_merge_for_exact_tested_head(self) -> None: + self.assertEqual( + 'b' * 40, + MergeResult( + head='a' * 40, + state='merged', + commit='b' * 40, + ).validate('a' * 40), + ) + + def test_rejects_merged_state_for_a_different_final_head(self) -> None: + with self.assertRaises(HeadChangedError): + MergeResult( + head='c' * 40, + state='merged', + commit='b' * 40, + ).validate('a' * 40) + + class TargetTest(unittest.TestCase): def test_accepts_exact_tag_and_release_targets(self) -> None: validate_tag_target( @@ -369,5 +428,150 @@ def test_rejects_release_target_mismatch(self) -> None: ) +class RecoveryTest(unittest.TestCase): + def merge( + self, + *, + number: int = 75, + target: str = 'a' * 40, + body: str = '', + files: tuple[str, ...] = ('Dockerfile',), + ) -> Merge: + return Merge( + number=number, + target=target, + base='main', + branch='automation/dependencies-100-1', + body=body, + files=files, + state='merged', + ) + + def release( + self, + *, + identifier: int = 10, + tag: str = '1.4.5', + target: str = 'a' * 40, + pull: int = 75, + draft: bool = True, + ) -> Recovery: + return Recovery( + identifier=identifier, + tag=tag, + target=target, + pull=pull, + draft=draft, + prerelease=False, + body=( + '\n' + f'\n' + f'' + ), + ) + + def test_resumes_draft_after_publish_failure(self) -> None: + target = 'a' * 40 + + self.assertEqual( + Candidate( + tag='1.4.5', + target=target, + pull=75, + draft=10, + ), + Recovery.select( + [Tag(name='1.4.5', target=target)], + [ + self.release(), + self.release( + identifier=9, + tag='1.4.4', + draft=False, + ), + ], + [self.merge()], + ) + ) + + def test_resumes_tag_when_draft_creation_failed_and_next_run_has_no_diff( + self, + ) -> None: + target = 'a' * 40 + + self.assertEqual( + Candidate( + tag='1.4.5', + target=target, + pull=75, + draft=None, + ), + Recovery.select( + [Tag(name='1.4.5', target=target)], + [ + self.release( + identifier=9, + tag='1.4.4', + draft=False, + ) + ], + [self.merge()], + ) + ) + + def test_ignores_unrelated_orphan_tag(self) -> None: + self.assertIsNone( + Recovery.select( + [Tag(name='9.9.9', target='b' * 40)], + [self.release(tag='1.4.4', draft=False)], + [self.merge()], + ) + ) + + def test_ignores_tag_for_unmarked_or_multi_file_pull_request(self) -> None: + target = 'a' * 40 + + self.assertIsNone( + Recovery.select( + [Tag(name='1.4.5', target=target)], + [self.release(tag='1.4.4', draft=False)], + [ + self.merge(body='No automation marker'), + self.merge(files=('Dockerfile', 'README.md')), + ], + ) + ) + + def test_fails_closed_for_multiple_recoverable_releases(self) -> None: + first = 'a' * 40 + second = 'b' * 40 + + with self.assertRaises(RecoveryError): + Recovery.select( + [ + Tag(name='1.4.5', target=first), + Tag(name='1.4.6', target=second), + ], + [self.release(tag='1.4.4', draft=False)], + [ + self.merge(target=first), + self.merge(number=76, target=second), + ], + ) + + def test_does_not_resume_wrong_target_draft(self) -> None: + target = 'a' * 40 + + with self.assertRaises(RecoveryError): + Recovery.select( + [Tag(name='1.4.5', target=target)], + [ + self.release(tag='1.4.4', draft=False), + self.release(target='b' * 40), + ], + [self.merge()], + ) + + if __name__ == '__main__': unittest.main() diff --git a/.github/scripts/test_orchestrator.py b/.github/scripts/test_orchestrator.py new file mode 100644 index 0000000..7187afb --- /dev/null +++ b/.github/scripts/test_orchestrator.py @@ -0,0 +1,97 @@ +import subprocess +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, call + + +sys.path.insert(0, str(Path(__file__).parent)) + +from automation import Tag, TargetMismatchError # noqa: E402 +from orchestrator import Orchestrator # noqa: E402 + + +class OrchestratorTest(unittest.TestCase): + def orchestrator(self) -> Orchestrator: + orchestrator = object.__new__(Orchestrator) + orchestrator.repository = 'appwrite/docker-base' + orchestrator.version = '2026-03-10' + orchestrator.header = 'X-GitHub-Api-Version: 2026-03-10' + return orchestrator + + def result( + self, + *, + returncode: int = 0, + output: str = '{}', + ) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=('gh',), + returncode=returncode, + stdout=output, + stderr='failure', + ) + + def test_does_not_publish_when_prepublication_target_changed(self) -> None: + orchestrator = self.orchestrator() + orchestrator._read_tag = Mock( + return_value=Tag(name='1.4.5', target='wrong') + ) + orchestrator._api = Mock() + + with self.assertRaises(TargetMismatchError): + orchestrator.publish( + tag='1.4.5', + target='expected', + pull=75, + draft=10, + ) + + orchestrator._api.assert_not_called() + + def test_returns_release_to_draft_when_postpublication_target_changed( + self, + ) -> None: + orchestrator = self.orchestrator() + orchestrator._read_tag = Mock( + side_effect=[ + Tag(name='1.4.5', target='expected'), + Tag(name='1.4.5', target='wrong'), + ] + ) + orchestrator._read_release = Mock( + side_effect=[ + {'draft': True}, + {'draft': True}, + ] + ) + orchestrator._validate_draft = Mock(return_value=10) + orchestrator._api = Mock( + side_effect=[ + self.result( + output=( + '{"tag_name":"1.4.5","draft":false,' + '"prerelease":false}' + ) + ), + self.result(), + ] + ) + + with self.assertRaisesRegex(RuntimeError, 'returned to draft'): + orchestrator.publish( + tag='1.4.5', + target='expected', + pull=75, + draft=10, + ) + + self.assertEqual( + call( + 'PATCH', + 'repos/appwrite/docker-base/releases/10', + (('-F', 'draft=true'),), + check=False, + ), + orchestrator._api.call_args_list[1], + ) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index bc960e4..939fe22 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -21,7 +21,7 @@ env: jobs: update: runs-on: ubuntu-24.04 - timeout-minutes: 180 + timeout-minutes: 300 steps: - name: Checkout repository uses: actions/checkout@v6.1.0 @@ -36,8 +36,15 @@ jobs: --pattern 'test_*.py' \ --verbose + - name: Recover an incomplete dependency release + id: recover + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: python3 .github/scripts/orchestrator.py recover + - name: Update dependencies id: update + if: steps.recover.outputs.pending != 'true' run: | report="${RUNNER_TEMP}/dependencies.md" python3 .github/scripts/dependencies.py | tee "${report}" @@ -107,6 +114,7 @@ jobs: head='${{ steps.push.outputs.head }}' body="${RUNNER_TEMP}/pull-request.md" { + echo '' echo 'Automated weekly dependency update.' echo cat "${RUNNER_TEMP}/dependencies.md" @@ -123,7 +131,7 @@ jobs: pull="$( gh pr view "${url}" \ --repo "${GITHUB_REPOSITORY}" \ - --json baseRefName,createdAt,headRefName,headRefOid,number,state \ + --json baseRefName,baseRefOid,createdAt,headRefName,headRefOid,number,state \ --jq '.' )" PULL="${pull}" python3 - "${branch}" "${head}" <<'PY' @@ -146,6 +154,10 @@ jobs: PULL="${pull}" python3 -c \ 'import json, os; print(json.loads(os.environ["PULL"])["number"])' )" + printf 'base=%s\n' "$( + PULL="${pull}" python3 -c \ + 'import json, os; print(json.loads(os.environ["PULL"])["baseRefOid"])' + )" printf 'url=%s\n' "${url}" } >> "${GITHUB_OUTPUT}" @@ -320,13 +332,14 @@ jobs: run: | number='${{ steps.pull.outputs.number }}' head='${{ steps.push.outputs.head }}' + base='${{ steps.pull.outputs.base }}' pull="$( gh pr view "${number}" \ --repo "${GITHUB_REPOSITORY}" \ - --json baseRefName,headRefOid,mergeable,number,reviewDecision,state \ + --json baseRefName,baseRefOid,headRefOid,mergeable,number,reviewDecision,state \ --jq '.' )" - PULL="${pull}" python3 - "${head}" <<'PY' + PULL="${pull}" python3 - "${head}" "${base}" <<'PY' import json import os import sys @@ -341,7 +354,7 @@ jobs: validate_pull_request, ) - expected = sys.argv[1] + expected_head, expected_base = sys.argv[1:] payload = json.loads(os.environ['PULL']) if payload['baseRefName'] != 'main': raise PullRequestUnavailableError( @@ -357,10 +370,12 @@ jobs: PullRequest( number=payload['number'], head=payload['headRefOid'], + base=payload['baseRefOid'], state=payload['state'].lower(), review=review, ), - expected, + expected_head, + expected_base, ) if payload['mergeable'] != 'MERGEABLE': raise PullRequestUnavailableError( @@ -368,247 +383,83 @@ jobs: ) PY - if ! gh pr merge "${number}" \ + gh pr merge "${number}" \ --repo "${GITHUB_REPOSITORY}" \ --squash \ - --delete-branch \ - --match-head-commit "${head}"; then - echo 'The merge command failed; checking the pull request state.' >&2 - fi + --match-head-commit "${head}" - merged="$( + final="$( gh pr view "${number}" \ --repo "${GITHUB_REPOSITORY}" \ - --json mergeCommit,state \ - --jq 'select(.state == "MERGED") | .mergeCommit.oid' + --json headRefOid,mergeCommit,state \ + --jq '.' )" - if [[ ! "${merged}" =~ ^[0-9a-f]{40,64}$ ]]; then - echo 'Pull request did not produce a merge commit.' >&2 - exit 1 - fi - printf 'head=%s\n' "${merged}" >> "${GITHUB_OUTPUT}" - - - name: Create and verify lightweight tag - id: tag - if: steps.update.outputs.changed == 'true' - env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} - MERGE_HEAD: ${{ steps.merge.outputs.head }} - run: | - python3 - <<'PY' + merged="$( + FINAL="${final}" EXPECTED="${head}" python3 - <<'PY' import json import os - import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path('.github/scripts').resolve())) - from automation import ( - Tag, - patch_after_collision, - release_candidate, - validate_tag_target, - ) - - repository = os.environ['GITHUB_REPOSITORY'] - version = os.environ['GITHUB_API_VERSION'] - merge = os.environ['MERGE_HEAD'] - header = f'X-GitHub-Api-Version: {version}' - - def tags() -> tuple[str, ...]: - result = subprocess.run( - ( - 'gh', - 'api', - '--paginate', - '-X', - 'GET', - ( - f'repos/{repository}/git/' - 'matching-refs/tags/' - ), - '-H', - header, - '-f', - 'per_page=100', - '--jq', - '.[].ref', - ), - check=True, - stdout=subprocess.PIPE, - text=True, - ) - prefix = 'refs/tags/' - return tuple( - line[len(prefix):] - for line in result.stdout.splitlines() - if line.startswith(prefix) - ) - - def create(tag: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ( - 'gh', - 'api', - '-X', - 'POST', - f'repos/{repository}/git/refs', - '-H', - header, - '-f', - f'ref=refs/tags/{tag}', - '-f', - f'sha={merge}', - '--silent', - ), - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - def releases() -> tuple[str, ...]: - result = subprocess.run( - ( - 'gh', - 'api', - '--paginate', - '-X', - 'GET', - f'repos/{repository}/releases', - '-H', - header, - '-f', - 'per_page=100', - '--jq', - '.[].tag_name', - ), - check=True, - stdout=subprocess.PIPE, - text=True, - ) - return tuple(result.stdout.splitlines()) - - def read(tag: str) -> dict[str, object] | None: - result = subprocess.run( - ( - 'gh', - 'api', - '-X', - 'GET', - f'repos/{repository}/git/ref/tags/{tag}', - '-H', - header, + from automation import MergeResult + + pull = json.loads(os.environ['FINAL']) + merge = pull.get('mergeCommit') + print( + MergeResult( + head=pull['headRefOid'], + state=pull['state'].lower(), + commit=( + str(merge['oid']) + if isinstance(merge, dict) + else None ), - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - return json.loads(result.stdout) if result.returncode == 0 else None - - available = tags() - candidate = str(release_candidate(available, releases())) - while True: - result = create(candidate) - if result.returncode == 0: - break - existing = read(candidate) - if existing is None: - sys.stderr.write(result.stderr) - raise RuntimeError(f'Failed to create tag {candidate}') - target = existing.get('object') - if ( - isinstance(target, dict) - and target.get('type') == 'commit' - and target.get('sha') == merge - ): - break - print(f'Tag {candidate} collided; refreshing remote tags.') - available = tags() - candidate = str( - patch_after_collision(available, candidate) - ) - - payload = read(candidate) - if payload is None: - raise RuntimeError(f'Tag {candidate} is missing after creation') - target = payload['object'] - if not isinstance(target, dict) or target.get('type') != 'commit': - raise RuntimeError(f'Tag {candidate} is not lightweight') - validate_tag_target( - Tag( - name=str(payload.get('ref', '')).removeprefix('refs/tags/'), - target=str(target.get('sha')), - ), - expected_name=candidate, - expected_target=merge, + ).validate(os.environ['EXPECTED']) ) - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output: - print(f'name={candidate}', file=output) PY + )" + if [[ ! "${merged}" =~ ^[0-9a-f]{40,64}$ ]]; then + echo 'Pull request did not produce a merge commit.' >&2 + exit 1 + fi + printf 'head=%s\n' "${merged}" >> "${GITHUB_OUTPUT}" - - name: Create and verify release - if: steps.update.outputs.changed == 'true' + - 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 }} - MERGE_HEAD: ${{ steps.merge.outputs.head }} - TAG: ${{ steps.tag.outputs.name }} + HEAD: >- + ${{ steps.merge.outputs.head || steps.recover.outputs.head }} + PULL: >- + ${{ steps.pull.outputs.number || steps.recover.outputs.pull }} + TAG: ${{ steps.recover.outputs.tag }} run: | - gh release create "${TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --verify-tag \ - --target "${MERGE_HEAD}" \ - --generate-notes \ - --latest \ - --fail-on-no-commits - - release="$( - gh api \ - -X GET \ - -H "X-GitHub-Api-Version: ${GITHUB_API_VERSION}" \ - "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" - )" - tag="$( - gh api \ - -X GET \ - -H "X-GitHub-Api-Version: ${GITHUB_API_VERSION}" \ - "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" - )" - RELEASE="${release}" TAG_REF="${tag}" python3 - <<'PY' - import json - import os - import sys - from pathlib import Path - - sys.path.insert(0, str(Path('.github/scripts').resolve())) + python3 .github/scripts/orchestrator.py prepare \ + "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" - from automation import ( - Release, - Tag, - validate_release_target, - validate_tag_target, - ) + - 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: | + python3 .github/scripts/orchestrator.py wait "${TAG}" "${HEAD}" - expected_tag = os.environ['TAG'] - expected_target = os.environ['MERGE_HEAD'] - release = json.loads(os.environ['RELEASE']) - tag = json.loads(os.environ['TAG_REF']) - target = tag['object'] - if not isinstance(target, dict) or target.get('type') != 'commit': - raise RuntimeError(f'Tag {expected_tag} is not lightweight') - actual_target = str(target.get('sha')) - validate_tag_target( - Tag(name=tag['ref'].removeprefix('refs/tags/'), target=actual_target), - expected_name=expected_tag, - expected_target=expected_target, - ) - validate_release_target( - Release(tag=release['tag_name'], target=actual_target), - expected_tag=expected_tag, - expected_target=expected_target, - ) - if release['draft'] or release['prerelease']: - raise RuntimeError('Release is not a published stable release') - PY + - 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: | + python3 .github/scripts/orchestrator.py publish \ + "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" From b66e1dbf6eeffd0e296421d738fe75e3745a1122 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 16:04:30 +1200 Subject: [PATCH 08/19] (fix): prove dependency release recovery state --- .github/scripts/automation/candidate.py | 2 +- .github/scripts/automation/merge.py | 29 +- .github/scripts/automation/merge_result.py | 11 +- .github/scripts/automation/recovery.py | 40 +- .github/scripts/orchestrator.py | 399 ++++++++++++++++---- .github/scripts/test_automation.py | 89 ++++- .github/scripts/test_orchestrator.py | 405 ++++++++++++++++++++- .github/workflows/dependencies.yml | 114 +----- 8 files changed, 907 insertions(+), 182 deletions(-) diff --git a/.github/scripts/automation/candidate.py b/.github/scripts/automation/candidate.py index f72281c..5f36109 100644 --- a/.github/scripts/automation/candidate.py +++ b/.github/scripts/automation/candidate.py @@ -5,7 +5,7 @@ class Candidate: """A uniquely recoverable dependency release.""" - tag: str + tag: str | None target: str pull: int draft: int | None diff --git a/.github/scripts/automation/merge.py b/.github/scripts/automation/merge.py index ed2fa41..2cddf3f 100644 --- a/.github/scripts/automation/merge.py +++ b/.github/scripts/automation/merge.py @@ -9,6 +9,8 @@ class Merge: number: int target: str + head: str + parents: tuple[str, ...] base: str branch: str body: str @@ -17,10 +19,35 @@ class Merge: def is_automation(self) -> bool: """Return whether all immutable automation provenance facts match.""" + lines = self.body.splitlines() + head = f'' + parent = ( + f'' + if len(self.parents) == 1 + else '' + ) return ( self.state == 'merged' and self.base == 'main' and self.branch.startswith('automation/dependencies-') - and self.marker in self.body + and lines.count(self.marker) == 1 + and lines.count(head) == 1 + and sum( + line.startswith('' in self.body - and f'' in self.body + and lines.count(self.marker) == 1 + and lines.count( + f'' + ) == 1 + and lines.count( + f'' + ) == 1 and self.pull in {0, merge.number} and self.target == merge.target ) @@ -43,21 +48,26 @@ def select( releases: Sequence[Recovery], merges: Sequence[Merge], ) -> Candidate | None: - """Select one qualified unpublished tag or fail closed.""" - published = { + """Select one qualified unpublished merge or fail closed.""" + released = { release.tag for release in releases if not release.draft } - stable_published = Version.stable(published) + stable_published = Version.stable( + release.tag + for release in releases + if not release.draft and not release.prerelease + ) threshold = stable_published[-1] if stable_published else None candidates: list[Candidate] = [] + targets = {tag.target for tag in tags} for tag in tags: version = Version.parse(tag.name) if ( version is None - or tag.name in published + or tag.name in released or (threshold is not None and version <= threshold) ): continue @@ -89,6 +99,17 @@ def select( ) ) + candidates.extend( + Candidate( + tag=None, + target=merge.target, + pull=merge.number, + draft=None, + ) + for merge in merges + if merge.target not in targets and merge.is_automation() + ) + unique = { (candidate.tag, candidate.target, candidate.pull, candidate.draft): candidate @@ -96,7 +117,10 @@ def select( } if len(unique) > 1: names = ', '.join( - sorted(candidate.tag for candidate in unique.values()) + sorted( + candidate.tag or f'pull request #{candidate.pull}' + for candidate in unique.values() + ) ) raise RecoveryError( f'Multiple dependency releases are recoverable: {names}' diff --git a/.github/scripts/orchestrator.py b/.github/scripts/orchestrator.py index 7112080..c6b709d 100644 --- a/.github/scripts/orchestrator.py +++ b/.github/scripts/orchestrator.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Sequence +from urllib.parse import quote sys.path.insert(0, str(Path(__file__).parent)) @@ -17,8 +18,12 @@ from automation import ( # noqa: E402 Deadline, Merge, + MergeResult, + PullRequest, + PullRequestUnavailableError, Recovery, Release, + ReviewDecision, Run, Tag, Version, @@ -46,6 +51,13 @@ def execute(self, arguments: Sequence[str]) -> None: if operation == 'recover' and not values: self.recover() return + if operation == 'merge' and len(values) == 3: + self.merge( + pull=int(values[0]), + head=values[1], + base=values[2], + ) + return if operation == 'prepare' and len(values) == 4: tag, target, pull, draft = values self.prepare( @@ -151,7 +163,7 @@ def _tags(self) -> tuple[Tag, ...]: and item['object'].get('type') == 'commit' ) - def _releases(self) -> tuple[Recovery, ...]: + def _listed_releases(self) -> tuple[Recovery, ...]: return tuple( Recovery( identifier=int(item['id']), @@ -165,45 +177,224 @@ def _releases(self) -> tuple[Recovery, ...]: for item in self._pages(f'repos/{self.repository}/releases') ) - def _merges(self, tags: Sequence[Tag]) -> tuple[Merge, ...]: - merges: list[Merge] = [] - for target in {tag.target for tag in tags}: - pulls = self._pages( - f'repos/{self.repository}/commits/{target}/pulls' + def _read_graphql_release(self, tag: str) -> dict[str, Any] | None: + owner, separator, name = self.repository.partition('/') + if not separator or not owner or not name: + raise RuntimeError( + f'Invalid GitHub repository {self.repository!r}' + ) + query = ( + 'query($owner:String!,$name:String!,$tag:String!){' + 'repository(owner:$owner,name:$name){' + 'release(tagName:$tag){' + 'databaseId tagName isDraft isPrerelease description ' + 'tagCommit{oid}' + '}}}' + ) + result = self._api( + 'POST', + 'graphql', + ( + ('-f', f'owner={owner}'), + ('-f', f'name={name}'), + ('-f', f'tag={tag}'), + ('-f', f'query={query}'), + ), + ) + payload = json.loads(result.stdout) + if payload.get('errors'): + raise RuntimeError( + f'GitHub GraphQL release lookup failed for {tag}' ) - for pull in pulls: - number = int(pull['number']) - files = tuple( - str(item['filename']) - for item in self._pages( - f'repos/{self.repository}/pulls/{number}/files' - ) + data = payload.get('data') + if not isinstance(data, dict): + raise RuntimeError(f'Release lookup for {tag} is invalid') + repository = data.get('repository') + if not isinstance(repository, dict): + raise RuntimeError(f'Release lookup for {tag} is invalid') + release = repository.get('release') + if release is None: + return None + if not isinstance(release, dict): + raise RuntimeError(f'Release lookup for {tag} is invalid') + commit = release.get('tagCommit') + target = ( + str(commit.get('oid', '')) + if isinstance(commit, dict) + else '' + ) + return { + 'id': release['databaseId'], + 'tag_name': release['tagName'], + 'target_commitish': target, + 'draft': release['isDraft'], + 'prerelease': release['isPrerelease'], + 'body': release.get('description') or '', + } + + def _read_release_by_tag(self, tag: str) -> dict[str, Any] | None: + result = self._api( + 'GET', + ( + f'repos/{self.repository}/releases/tags/' + f'{quote(tag, safe="")}' + ), + check=False, + ) + if result.returncode == 0: + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise RuntimeError(f'Release lookup for {tag} is invalid') + return payload + + try: + error = json.loads(result.stdout) + except json.JSONDecodeError as exception: + raise RuntimeError( + f'Release lookup failed for {tag}' + ) from exception + if not isinstance(error, dict) or str(error.get('status')) != '404': + sys.stderr.write(result.stderr) + raise RuntimeError(f'Release lookup failed for {tag}') + return self._read_graphql_release(tag) + + @staticmethod + def _release(payload: dict[str, Any]) -> Recovery: + return Recovery( + identifier=int(payload['id']), + tag=str(payload['tag_name']), + target=str(payload['target_commitish']), + pull=0, + draft=bool(payload['draft']), + prerelease=bool(payload['prerelease']), + body=str(payload.get('body') or ''), + ) + + def _releases(self, tags: Sequence[Tag]) -> tuple[Recovery, ...]: + listed = self._listed_releases() + stable: dict[str, tuple[Version, Tag]] = {} + for tag in tags: + version = Version.parse(tag.name) + if version is not None: + stable[tag.name] = (version, tag) + releases: dict[str, Recovery] = {} + threshold: Version | None = None + hints = sorted( + ( + release + for release in listed + if not release.draft + and not release.prerelease + and release.tag in stable + ), + key=lambda release: stable[release.tag][0], + reverse=True, + ) + for hint in hints: + payload = self._read_release_by_tag(hint.tag) + if payload is None: + continue + release = self._release(payload) + if release.tag != hint.tag: + raise RuntimeError( + f'Release lookup for {hint.tag} returned {release.tag}' + ) + releases[release.tag] = release + if not release.draft and not release.prerelease: + threshold = stable[release.tag][0] + break + + candidates = sorted( + ( + (version, tag) + for version, tag in stable.values() + if threshold is None or version > threshold + ), + reverse=True, + ) + for _, tag in candidates: + payload = self._read_release_by_tag(tag.name) + if payload is None: + continue + release = self._release(payload) + if release.tag != tag.name: + raise RuntimeError( + f'Release lookup for {tag.name} returned {release.tag}' ) - head = pull.get('head') - base = pull.get('base') - merged = ( - pull.get('merged_at') is not None - and pull.get('merge_commit_sha') == target + releases[release.tag] = release + return tuple(releases.values()) + + def _read_parents(self, commit: str) -> tuple[str, ...]: + result = self._api( + 'GET', + f'repos/{self.repository}/commits/{commit}', + ) + payload = json.loads(result.stdout) + parents = payload.get('parents') + if not isinstance(parents, list): + raise RuntimeError(f'Commit {commit} has invalid parents') + if any( + not isinstance(parent, dict) or not parent.get('sha') + for parent in parents + ): + raise RuntimeError(f'Commit {commit} has invalid parents') + return tuple( + str(parent['sha']) + for parent in parents + ) + + def _merges(self) -> tuple[Merge, ...]: + merges: list[Merge] = [] + pulls = self._pages( + f'repos/{self.repository}/pulls' + '?state=closed&base=main&sort=updated&direction=desc' + ) + for pull in pulls: + head = pull.get('head') + body = str(pull.get('body') or '') + branch = ( + str(head.get('ref', '')) + if isinstance(head, dict) + else '' + ) + if ( + pull.get('merged_at') is None + or Merge.marker not in body + or not branch.startswith('automation/dependencies-') + ): + continue + number = int(pull['number']) + details = self._read_pull(number) + commit = details.get('mergeCommit') + target = ( + str(commit.get('oid')) + if isinstance(commit, dict) and commit.get('oid') + else None + ) + if ( + str(details.get('state', '')).lower() != 'merged' + or target is None + ): + continue + files = tuple( + str(item['filename']) + for item in self._pages( + f'repos/{self.repository}/pulls/{number}/files' ) - merges.append( - Merge( - number=number, - target=target, - base=( - str(base.get('ref', '')) - if isinstance(base, dict) - else '' - ), - branch=( - str(head.get('ref', '')) - if isinstance(head, dict) - else '' - ), - body=str(pull.get('body') or ''), - files=files, - state='merged' if merged else str(pull.get('state')), - ) + ) + merges.append( + Merge( + number=number, + target=target, + head=str(details.get('headRefOid', '')), + parents=self._read_parents(target), + base=str(details.get('baseRefName', '')), + branch=branch, + body=body, + files=files, + state='merged', ) + ) return tuple(merges) def _read_tag(self, name: str) -> Tag | None: @@ -223,6 +414,92 @@ def _read_tag(self, name: str) -> Tag | None: target=str(target.get('sha')), ) + def _read_pull(self, number: int) -> dict[str, Any]: + result = self._run( + ( + 'gh', + 'pr', + 'view', + str(number), + '--repo', + self.repository, + '--json', + ( + 'baseRefName,baseRefOid,headRefOid,mergeCommit,' + 'mergeable,number,reviewDecision,state' + ), + '--jq', + '.', + ) + ) + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise RuntimeError(f'Pull request #{number} is invalid') + return payload + + def merge(self, *, pull: int, head: str, base: str) -> None: + """Merge and prove the exact tested head and base.""" + payload = self._read_pull(pull) + if payload.get('baseRefName') != 'main': + raise PullRequestUnavailableError( + 'Pull request does not target main' + ) + try: + review = ReviewDecision( + str(payload.get('reviewDecision', '')).lower() + ) + except ValueError as exception: + raise PullRequestUnavailableError( + 'Pull request has no current approval' + ) from exception + PullRequest( + number=int(payload['number']), + head=str(payload['headRefOid']), + base=str(payload['baseRefOid']), + state=str(payload['state']).lower(), + review=review, + ).validate(head, base) + if payload.get('mergeable') != 'MERGEABLE': + raise PullRequestUnavailableError( + 'Pull request is not currently mergeable' + ) + + result = self._run( + ( + 'gh', + 'pr', + 'merge', + str(pull), + '--repo', + self.repository, + '--squash', + '--match-head-commit', + head, + ), + check=False, + ) + final = self._read_pull(pull) + commit = final.get('mergeCommit') + target = ( + str(commit.get('oid')) + if isinstance(commit, dict) and commit.get('oid') + else None + ) + parents = self._read_parents(target) if target is not None else () + merged = MergeResult( + head=str(final.get('headRefOid', '')), + state=str(final.get('state', '')).lower(), + commit=target, + parents=parents, + ).validate(head, base) + if result.returncode != 0: + print( + 'The merge command exited nonzero, but GitHub proved the ' + 'exact tested squash merge.', + flush=True, + ) + self._write({'head': merged}) + def _read_release(self, identifier: int) -> dict[str, Any]: result = self._api( 'GET', @@ -261,6 +538,8 @@ def _validate_draft( merge = Merge( number=pull, target=target, + head='', + parents=(), base='main', branch='automation/dependencies-recovery', body=Merge.marker, @@ -273,27 +552,12 @@ def _validate_draft( def recover(self) -> None: """Recover only state proven to originate from this automation.""" - releases = self._releases() - published = { - release.tag - for release in releases - if not release.draft - } - threshold = Version.stable(published) - latest = threshold[-1] if threshold else None - tags = tuple( - tag - for tag in self._tags() - if ( - (version := Version.parse(tag.name)) is not None - and tag.name not in published - and (latest is None or version > latest) - ) - ) + all_tags = self._tags() + releases = self._releases(all_tags) candidate = Recovery.select( - tags, + all_tags, releases, - self._merges(tags), + self._merges(), ) if candidate is None: self._write({'pending': 'false'}) @@ -302,7 +566,7 @@ def recover(self) -> None: self._write( { 'pending': 'true', - 'tag': candidate.tag, + 'tag': candidate.tag or '', 'head': candidate.target, 'pull': str(candidate.pull), 'draft': ( @@ -351,6 +615,17 @@ def _create_tag(self, target: str) -> str: return candidate def _create_draft(self, tag: str, target: str, pull: int) -> int: + existing = self._read_release_by_tag(tag) + if existing is not None: + if existing.get('draft') is not True: + raise RuntimeError(f'Release {tag} is already published') + return self._validate_draft( + existing, + tag=tag, + target=target, + pull=pull, + ) + body = self._draft_body(target, pull) result = self._api( 'POST', @@ -375,16 +650,14 @@ def _create_draft(self, tag: str, target: str, pull: int) -> int: pull=pull, ) - matches = [ - release - for release in self._releases() - if release.tag == tag - ] - if len(matches) != 1: + existing = self._read_release_by_tag(tag) + if existing is None: sys.stderr.write(result.stderr) raise RuntimeError(f'Failed to create draft release {tag}') + if existing.get('draft') is not True: + raise RuntimeError(f'Release {tag} was published concurrently') return self._validate_draft( - self._read_release(matches[0].identifier), + existing, tag=tag, target=target, pull=pull, diff --git a/.github/scripts/test_automation.py b/.github/scripts/test_automation.py index ed220db..7e7568c 100644 --- a/.github/scripts/test_automation.py +++ b/.github/scripts/test_automation.py @@ -386,7 +386,8 @@ def test_accepts_merge_for_exact_tested_head(self) -> None: head='a' * 40, state='merged', commit='b' * 40, - ).validate('a' * 40), + parents=('c' * 40,), + ).validate('a' * 40, 'c' * 40), ) def test_rejects_merged_state_for_a_different_final_head(self) -> None: @@ -395,7 +396,26 @@ def test_rejects_merged_state_for_a_different_final_head(self) -> None: head='c' * 40, state='merged', commit='b' * 40, - ).validate('a' * 40) + parents=('d' * 40,), + ).validate('a' * 40, 'd' * 40) + + def test_rejects_merge_commit_for_a_different_tested_base(self) -> None: + with self.assertRaises(HeadChangedError): + MergeResult( + head='a' * 40, + state='merged', + commit='b' * 40, + parents=('e' * 40,), + ).validate('a' * 40, 'd' * 40) + + def test_rejects_a_non_squash_merge_commit(self) -> None: + with self.assertRaises(HeadChangedError): + MergeResult( + head='a' * 40, + state='merged', + commit='b' * 40, + parents=('c' * 40, 'd' * 40), + ).validate('a' * 40, 'c' * 40) class TargetTest(unittest.TestCase): @@ -434,15 +454,29 @@ def merge( *, number: int = 75, target: str = 'a' * 40, - body: str = '', + head: str = 'b' * 40, + parent: str = 'c' * 40, + tested_base: str | None = None, + body: str | None = None, files: tuple[str, ...] = ('Dockerfile',), ) -> Merge: + proof = parent if tested_base is None else tested_base return Merge( number=number, target=target, + head=head, + parents=(parent,), base='main', branch='automation/dependencies-100-1', - body=body, + body=( + ( + '\n' + f'\n' + f'' + ) + if body is None + else body + ), files=files, state='merged', ) @@ -519,12 +553,57 @@ def test_resumes_tag_when_draft_creation_failed_and_next_run_has_no_diff( ) ) + def test_resumes_proven_merge_when_cancelled_before_tag_creation( + self, + ) -> None: + target = 'd' * 40 + + self.assertEqual( + Candidate( + tag=None, + target=target, + pull=76, + draft=None, + ), + Recovery.select( + [Tag(name='1.4.4', target='a' * 40)], + [self.release(tag='1.4.4', draft=False)], + [self.merge(number=76, target=target)], + ), + ) + + def test_does_not_resume_merge_of_an_untested_base(self) -> None: + self.assertIsNone( + Recovery.select( + [Tag(name='1.4.4', target='a' * 40)], + [self.release(tag='1.4.4', draft=False)], + [ + self.merge( + target='d' * 40, + parent='e' * 40, + tested_base='c' * 40, + ) + ], + ) + ) + + def test_fails_closed_for_ambiguous_proven_untagged_merges(self) -> None: + with self.assertRaises(RecoveryError): + Recovery.select( + [Tag(name='1.4.4', target='a' * 40)], + [self.release(tag='1.4.4', draft=False)], + [ + self.merge(number=76, target='d' * 40), + self.merge(number=77, target='e' * 40), + ], + ) + def test_ignores_unrelated_orphan_tag(self) -> None: self.assertIsNone( Recovery.select( [Tag(name='9.9.9', target='b' * 40)], [self.release(tag='1.4.4', draft=False)], - [self.merge()], + [self.merge(body='No automation marker')], ) ) diff --git a/.github/scripts/test_orchestrator.py b/.github/scripts/test_orchestrator.py index 7187afb..c45619c 100644 --- a/.github/scripts/test_orchestrator.py +++ b/.github/scripts/test_orchestrator.py @@ -7,7 +7,14 @@ sys.path.insert(0, str(Path(__file__).parent)) -from automation import Tag, TargetMismatchError # noqa: E402 +from automation import ( # noqa: E402 + HeadChangedError, + Merge, + PullRequestUnavailableError, + Recovery, + Tag, + TargetMismatchError, +) from orchestrator import Orchestrator # noqa: E402 @@ -32,6 +39,402 @@ def result( stderr='failure', ) + def release( + self, + *, + identifier: int, + tag: str, + target: str, + draft: bool, + prerelease: bool = False, + body: str = '', + ) -> dict[str, object]: + return { + 'id': identifier, + 'tag_name': tag, + 'target_commitish': target, + 'draft': draft, + 'prerelease': prerelease, + 'body': body, + } + + def pull( + self, + *, + head: str, + base: str, + state: str = 'OPEN', + commit: str | None = None, + ) -> dict[str, object]: + return { + 'baseRefName': 'main', + 'baseRefOid': base, + 'headRefOid': head, + 'mergeCommit': ( + {'oid': commit} + if commit is not None + else None + ), + 'mergeable': 'MERGEABLE', + 'number': 75, + 'reviewDecision': 'APPROVED', + 'state': state, + } + + def test_handles_missing_release_by_tag_as_none(self) -> None: + orchestrator = self.orchestrator() + orchestrator._api = Mock( + return_value=self.result( + returncode=1, + output='{"status":"404"}', + ) + ) + orchestrator._read_graphql_release = Mock(return_value=None) + + self.assertIsNone( + orchestrator._read_release_by_tag('1.4.5') + ) + + def test_finds_draft_after_release_by_tag_returns_404(self) -> None: + orchestrator = self.orchestrator() + draft = self.release( + identifier=10, + tag='1.4.5', + target='a' * 40, + draft=True, + ) + orchestrator._api = Mock( + return_value=self.result( + returncode=1, + output='{"status":"404"}', + ) + ) + orchestrator._read_graphql_release = Mock(return_value=draft) + + self.assertIs( + draft, + orchestrator._read_release_by_tag('1.4.5'), + ) + + def test_exact_lookup_finds_published_release_omitted_from_list( + self, + ) -> None: + orchestrator = self.orchestrator() + first = 'a' * 40 + second = 'b' * 40 + orchestrator._listed_releases = Mock( + return_value=( + Recovery( + identifier=9, + tag='1.4.3', + target=first, + pull=0, + draft=False, + prerelease=False, + body='', + ), + ) + ) + payloads = { + '1.4.3': self.release( + identifier=9, + tag='1.4.3', + target=first, + draft=False, + ), + '1.4.4': self.release( + identifier=10, + tag='1.4.4', + target=second, + draft=False, + ), + } + orchestrator._read_release_by_tag = Mock( + side_effect=payloads.get + ) + + releases = orchestrator._releases( + ( + Tag(name='1.4.3', target=first), + Tag(name='1.4.4', target=second), + ) + ) + + self.assertEqual( + {'1.4.3', '1.4.4'}, + {release.tag for release in releases}, + ) + orchestrator._read_release_by_tag.assert_has_calls( + [call('1.4.3'), call('1.4.4')] + ) + + def test_recovers_merge_cancelled_before_tag_on_next_no_diff_run( + self, + ) -> None: + orchestrator = self.orchestrator() + released = 'a' * 40 + target = 'b' * 40 + head = 'c' * 40 + base = 'd' * 40 + orchestrator._tags = Mock( + return_value=(Tag(name='1.4.4', target=released),) + ) + orchestrator._releases = Mock( + return_value=( + Recovery( + identifier=9, + tag='1.4.4', + target=released, + pull=0, + draft=False, + prerelease=False, + body='', + ), + ) + ) + orchestrator._merges = Mock( + return_value=( + Merge( + number=75, + target=target, + head=head, + parents=(base,), + base='main', + branch='automation/dependencies-100-1', + body=( + '\n' + f'\n' + f'' + ), + files=('Dockerfile',), + state='merged', + ), + ) + ) + orchestrator._write = Mock() + + orchestrator.recover() + + orchestrator._write.assert_called_once_with( + { + 'pending': 'true', + 'tag': '', + 'head': target, + 'pull': '75', + 'draft': '', + } + ) + + def test_resolves_recovery_commit_when_rest_merge_sha_is_null( + self, + ) -> None: + orchestrator = self.orchestrator() + head = 'a' * 40 + base = 'b' * 40 + commit = 'c' * 40 + body = ( + '\n' + f'\n' + f'' + ) + orchestrator._pages = Mock( + side_effect=[ + [ + { + 'number': 75, + 'merged_at': '2026-07-24T00:00:00Z', + 'merge_commit_sha': None, + 'head': { + 'ref': 'automation/dependencies-100-1', + 'sha': head, + }, + 'base': {'ref': 'main'}, + 'body': body, + } + ], + [{'filename': 'Dockerfile'}], + ] + ) + orchestrator._read_pull = Mock( + return_value=self.pull( + head=head, + base=base, + state='MERGED', + commit=commit, + ) + ) + orchestrator._read_parents = Mock(return_value=(base,)) + + self.assertEqual( + ( + Merge( + number=75, + target=commit, + head=head, + parents=(base,), + base='main', + branch='automation/dependencies-100-1', + body=body, + files=('Dockerfile',), + state='merged', + ), + ), + orchestrator._merges(), + ) + + def test_accepts_nonzero_merge_only_after_exact_remote_proof( + self, + ) -> None: + orchestrator = self.orchestrator() + head = 'a' * 40 + base = 'b' * 40 + commit = 'c' * 40 + orchestrator._read_pull = Mock( + side_effect=[ + self.pull(head=head, base=base), + self.pull( + head=head, + base=base, + state='MERGED', + commit=commit, + ), + ] + ) + orchestrator._run = Mock( + return_value=self.result(returncode=1) + ) + orchestrator._read_parents = Mock(return_value=(base,)) + orchestrator._write = Mock() + + orchestrator.merge(pull=75, head=head, base=base) + + orchestrator._write.assert_called_once_with({'head': commit}) + + def test_rejects_nonzero_merge_without_merged_remote_state( + self, + ) -> None: + orchestrator = self.orchestrator() + head = 'a' * 40 + base = 'b' * 40 + orchestrator._read_pull = Mock( + side_effect=[ + self.pull(head=head, base=base), + self.pull(head=head, base=base), + ] + ) + orchestrator._run = Mock( + return_value=self.result(returncode=1) + ) + orchestrator._write = Mock() + + with self.assertRaises(PullRequestUnavailableError): + orchestrator.merge(pull=75, head=head, base=base) + + orchestrator._write.assert_not_called() + + def test_rejects_merge_when_squash_parent_is_not_tested_base( + self, + ) -> None: + orchestrator = self.orchestrator() + head = 'a' * 40 + base = 'b' * 40 + commit = 'c' * 40 + orchestrator._read_pull = Mock( + side_effect=[ + self.pull(head=head, base=base), + self.pull( + head=head, + base=base, + state='MERGED', + commit=commit, + ), + ] + ) + orchestrator._run = Mock(return_value=self.result()) + orchestrator._read_parents = Mock( + return_value=('d' * 40,) + ) + orchestrator._write = Mock() + + with self.assertRaises(HeadChangedError): + orchestrator.merge(pull=75, head=head, base=base) + + orchestrator._write.assert_not_called() + + def test_existing_published_release_prevents_duplicate_draft( + self, + ) -> None: + orchestrator = self.orchestrator() + orchestrator._read_release_by_tag = Mock( + return_value=self.release( + identifier=10, + tag='1.4.5', + target='a' * 40, + draft=False, + ) + ) + orchestrator._api = Mock() + + with self.assertRaisesRegex(RuntimeError, 'already published'): + orchestrator._create_draft( + tag='1.4.5', + target='a' * 40, + pull=75, + ) + + orchestrator._api.assert_not_called() + + def test_existing_exact_draft_avoids_duplicate_create(self) -> None: + orchestrator = self.orchestrator() + target = 'a' * 40 + body = orchestrator._draft_body(target, 75) + orchestrator._read_release_by_tag = Mock( + return_value=self.release( + identifier=10, + tag='1.4.5', + target=target, + draft=True, + body=body, + ) + ) + orchestrator._api = Mock() + + self.assertEqual( + 10, + orchestrator._create_draft( + tag='1.4.5', + target=target, + pull=75, + ), + ) + orchestrator._api.assert_not_called() + + def test_recovers_concurrently_created_draft_after_422(self) -> None: + orchestrator = self.orchestrator() + target = 'a' * 40 + draft = self.release( + identifier=10, + tag='1.4.5', + target=target, + draft=True, + body=orchestrator._draft_body(target, 75), + ) + orchestrator._read_release_by_tag = Mock( + side_effect=[None, draft] + ) + orchestrator._api = Mock( + return_value=self.result(returncode=1) + ) + + self.assertEqual( + 10, + orchestrator._create_draft( + tag='1.4.5', + target=target, + pull=75, + ), + ) + def test_does_not_publish_when_prepublication_target_changed(self) -> None: orchestrator = self.orchestrator() orchestrator._read_tag = Mock( diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 939fe22..22fe132 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -74,6 +74,7 @@ jobs: { 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}" @@ -112,9 +113,12 @@ jobs: 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" @@ -134,17 +138,19 @@ jobs: --json baseRefName,baseRefOid,createdAt,headRefName,headRefOid,number,state \ --jq '.' )" - PULL="${pull}" python3 - "${branch}" "${head}" <<'PY' + PULL="${pull}" python3 - "${branch}" "${head}" "${base}" <<'PY' import json import os import sys - branch, head = sys.argv[1:] + branch, head, base = sys.argv[1:] pull = json.loads(os.environ['PULL']) if pull['baseRefName'] != 'main': raise RuntimeError('Pull request does not target main') if pull['headRefName'] != branch or pull['headRefOid'] != head: raise RuntimeError('Pull request does not use the pushed head') + if pull['baseRefOid'] != base: + raise RuntimeError('Pull request does not use the tested base') if pull['state'] != 'OPEN': raise RuntimeError('Pull request is not open') PY @@ -154,10 +160,7 @@ jobs: PULL="${pull}" python3 -c \ 'import json, os; print(json.loads(os.environ["PULL"])["number"])' )" - printf 'base=%s\n' "$( - PULL="${pull}" python3 -c \ - 'import json, os; print(json.loads(os.environ["PULL"])["baseRefOid"])' - )" + printf 'base=%s\n' "${base}" printf 'url=%s\n' "${url}" } >> "${GITHUB_OUTPUT}" @@ -330,101 +333,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_TOKEN }} run: | - number='${{ steps.pull.outputs.number }}' - head='${{ steps.push.outputs.head }}' - base='${{ steps.pull.outputs.base }}' - pull="$( - gh pr view "${number}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json baseRefName,baseRefOid,headRefOid,mergeable,number,reviewDecision,state \ - --jq '.' - )" - PULL="${pull}" python3 - "${head}" "${base}" <<'PY' - import json - import os - import sys - from pathlib import Path - - sys.path.insert(0, str(Path('.github/scripts').resolve())) - - from automation import ( - PullRequest, - PullRequestUnavailableError, - ReviewDecision, - validate_pull_request, - ) - - expected_head, expected_base = sys.argv[1:] - payload = json.loads(os.environ['PULL']) - if payload['baseRefName'] != 'main': - raise PullRequestUnavailableError( - 'Pull request does not target main' - ) - try: - review = ReviewDecision(payload['reviewDecision'].lower()) - except ValueError as error: - raise PullRequestUnavailableError( - 'Pull request has no current approval' - ) from error - validate_pull_request( - PullRequest( - number=payload['number'], - head=payload['headRefOid'], - base=payload['baseRefOid'], - state=payload['state'].lower(), - review=review, - ), - expected_head, - expected_base, - ) - if payload['mergeable'] != 'MERGEABLE': - raise PullRequestUnavailableError( - 'Pull request is not currently mergeable' - ) - PY - - gh pr merge "${number}" \ - --repo "${GITHUB_REPOSITORY}" \ - --squash \ - --match-head-commit "${head}" - - final="$( - gh pr view "${number}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json headRefOid,mergeCommit,state \ - --jq '.' - )" - merged="$( - FINAL="${final}" EXPECTED="${head}" python3 - <<'PY' - import json - import os - import sys - from pathlib import Path - - sys.path.insert(0, str(Path('.github/scripts').resolve())) - - from automation import MergeResult - - pull = json.loads(os.environ['FINAL']) - merge = pull.get('mergeCommit') - print( - MergeResult( - head=pull['headRefOid'], - state=pull['state'].lower(), - commit=( - str(merge['oid']) - if isinstance(merge, dict) - else None - ), - ).validate(os.environ['EXPECTED']) - ) - PY - )" - if [[ ! "${merged}" =~ ^[0-9a-f]{40,64}$ ]]; then - echo 'Pull request did not produce a merge commit.' >&2 - exit 1 - fi - printf 'head=%s\n' "${merged}" >> "${GITHUB_OUTPUT}" + python3 .github/scripts/orchestrator.py merge \ + '${{ steps.pull.outputs.number }}' \ + '${{ steps.push.outputs.head }}' \ + '${{ steps.pull.outputs.base }}' - name: Prepare exact tag and draft release id: release From 476a00cbdaca1ac96c44015ff7d5c4bb80f0f5d1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 17:05:55 +1200 Subject: [PATCH 09/19] (refactor): make release policies native to PHP --- .../Automation/ApprovalMissingException.php | 9 + .../src/Automation/AutomationException.php | 11 + .github/scripts/src/Automation/Deadline.php | 40 +++ .../src/Automation/HeadChangedException.php | 9 + .../scripts/src/Automation/MergeValidator.php | 106 +++++++ .../PullRequestUnavailableException.php | 9 + .../src/Automation/PullRequestValidator.php | 50 +++ .../src/Automation/RecoveryException.php | 9 + .../src/Automation/RecoverySelector.php | 209 +++++++++++++ .../scripts/src/Automation/RunEvaluator.php | 101 +++++++ .../Automation/TargetMismatchException.php | 9 + .../src/Automation/TargetValidator.php | 45 +++ .github/scripts/src/Automation/Version.php | 207 +++++++++++++ .../Automation/VersionInvalidException.php | 9 + .../Automation/VersionMissingException.php | 9 + .../scripts/src/Automation/WorkflowOutput.php | 80 +++++ .../tests/Unit/Automation/DeadlineTest.php | 53 ++++ .../tests/Unit/Automation/MergeResultTest.php | 86 ++++++ .../tests/Unit/Automation/PullRequestTest.php | 96 ++++++ .../tests/Unit/Automation/RecoveryTest.php | 284 ++++++++++++++++++ .../tests/Unit/Automation/TargetTest.php | 56 ++++ .../tests/Unit/Automation/VersionTest.php | 136 +++++++++ .../tests/Unit/Automation/WorkflowTest.php | 255 ++++++++++++++++ 23 files changed, 1878 insertions(+) create mode 100644 .github/scripts/src/Automation/ApprovalMissingException.php create mode 100644 .github/scripts/src/Automation/AutomationException.php create mode 100644 .github/scripts/src/Automation/Deadline.php create mode 100644 .github/scripts/src/Automation/HeadChangedException.php create mode 100644 .github/scripts/src/Automation/MergeValidator.php create mode 100644 .github/scripts/src/Automation/PullRequestUnavailableException.php create mode 100644 .github/scripts/src/Automation/PullRequestValidator.php create mode 100644 .github/scripts/src/Automation/RecoveryException.php create mode 100644 .github/scripts/src/Automation/RecoverySelector.php create mode 100644 .github/scripts/src/Automation/RunEvaluator.php create mode 100644 .github/scripts/src/Automation/TargetMismatchException.php create mode 100644 .github/scripts/src/Automation/TargetValidator.php create mode 100644 .github/scripts/src/Automation/Version.php create mode 100644 .github/scripts/src/Automation/VersionInvalidException.php create mode 100644 .github/scripts/src/Automation/VersionMissingException.php create mode 100644 .github/scripts/src/Automation/WorkflowOutput.php create mode 100644 .github/scripts/tests/Unit/Automation/DeadlineTest.php create mode 100644 .github/scripts/tests/Unit/Automation/MergeResultTest.php create mode 100644 .github/scripts/tests/Unit/Automation/PullRequestTest.php create mode 100644 .github/scripts/tests/Unit/Automation/RecoveryTest.php create mode 100644 .github/scripts/tests/Unit/Automation/TargetTest.php create mode 100644 .github/scripts/tests/Unit/Automation/VersionTest.php create mode 100644 .github/scripts/tests/Unit/Automation/WorkflowTest.php 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 @@ +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, + '", + ) === 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/RunEvaluator.php b/.github/scripts/src/Automation/RunEvaluator.php new file mode 100644 index 0000000..7cbcc2b --- /dev/null +++ b/.github/scripts/src/Automation/RunEvaluator.php @@ -0,0 +1,101 @@ + $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/TargetMismatchException.php b/.github/scripts/src/Automation/TargetMismatchException.php new file mode 100644 index 0000000..785e617 --- /dev/null +++ b/.github/scripts/src/Automation/TargetMismatchException.php @@ -0,0 +1,9 @@ +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..43557b1 --- /dev/null +++ b/.github/scripts/src/Automation/Version.php @@ -0,0 +1,207 @@ + $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 + { + if ($this->patch === PHP_INT_MAX) { + throw new VersionInvalidException( + 'Patch version cannot be incremented', + ); + } + + return new self($this->major, $this->minor, $this->patch + 1); + } + + public function compare(self $other): int + { + return [$this->major, $this->minor, $this->patch] + <=> [$other->major, $other->minor, $other->patch]; + } + + #[\Override] + public function __toString(): string + { + return "{$this->major}.{$this->minor}.{$this->patch}"; + } + + private static function component(string $value): ?int + { + $component = filter_var( + $value, + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 0]], + ); + + return is_int($component) ? $component : null; + } +} 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/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/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..02cb937 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/VersionTest.php @@ -0,0 +1,136 @@ + (string) $version, + Version::stable([ + '1.10.0', + '2.0.0', + '1.2.99', + '1.10.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_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_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'); + } +} From bd9108856984b74e3779addaa60eb2816cefae88 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 17:05:05 +1200 Subject: [PATCH 10/19] (feat): port release orchestration to PHP --- .../scripts/src/Automation/Application.php | 138 +++ .../scripts/src/Automation/Orchestrator.php | 271 +++++ .../src/Automation/Repository/GitHub.php | 1003 +++++++++++++++++ .github/scripts/tests/Support/Queue.php | 66 ++ .../Unit/Automation/OrchestratorTest.php | 626 ++++++++++ 5 files changed, 2104 insertions(+) create mode 100644 .github/scripts/src/Automation/Application.php create mode 100644 .github/scripts/src/Automation/Orchestrator.php create mode 100644 .github/scripts/src/Automation/Repository/GitHub.php create mode 100644 .github/scripts/tests/Support/Queue.php create mode 100644 .github/scripts/tests/Unit/Automation/OrchestratorTest.php diff --git a/.github/scripts/src/Automation/Application.php b/.github/scripts/src/Automation/Application.php new file mode 100644 index 0000000..d940229 --- /dev/null +++ b/.github/scripts/src/Automation/Application.php @@ -0,0 +1,138 @@ + $arguments + * + * @return array + */ + public function execute(array $arguments): 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(), + ['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 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/Orchestrator.php b/.github/scripts/src/Automation/Orchestrator.php new file mode 100644 index 0000000..173c980 --- /dev/null +++ b/.github/scripts/src/Automation/Orchestrator.php @@ -0,0 +1,271 @@ +' + . "\n" + . "\n" + . "\n\nAutomated weekly dependency release."; + + private const int TIMEOUT = 7200; + + private const int INTERVAL = 20; + + public function __construct( + private Repository $repository, + private Clock $clock, + private Sleeper $sleeper, + ) { + } + + public function recover(): ?Candidate + { + return RecoverySelector::select( + $this->repository->tags(), + $this->repository->releases(), + $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 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/Repository/GitHub.php b/.github/scripts/src/Automation/Repository/GitHub.php new file mode 100644 index 0000000..aa71a43 --- /dev/null +++ b/.github/scripts/src/Automation/Repository/GitHub.php @@ -0,0 +1,1003 @@ +'; + + 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); + } + + /** + * @return list + */ + #[Override] + public function releases(): array + { + $tags = $this->tags(); + $stable = []; + foreach ($tags as $tag) { + if ($this->stable($tag->name)) { + $stable[$tag->name] = $tag; + } + } + + $listed = $this->listedReleases(); + usort( + $listed, + static fn (Recovery $left, Recovery $right): int => version_compare( + $right->tag, + $left->tag, + ), + ); + + $releases = []; + $threshold = null; + foreach ($listed as $hint) { + if ( + $hint->draft + || $hint->prerelease + || ! isset($stable[$hint->tag]) + ) { + continue; + } + + $release = $this->releaseByTag($hint->tag); + if ($release === null) { + continue; + } + $this->assertTag($release, $hint->tag); + $releases[$release->tag] = $release; + if (! $release->draft && ! $release->prerelease) { + $threshold = $release->tag; + break; + } + } + + $candidates = array_keys($stable); + usort( + $candidates, + static fn (string $left, string $right): int => version_compare( + $right, + $left, + ), + ); + foreach ($candidates as $tag) { + if ( + $threshold !== null + && version_compare($tag, $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 stable(string $version): bool + { + return preg_match( + '/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/D', + $version, + ) === 1; + } + + 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/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/Automation/OrchestratorTest.php b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php new file mode 100644 index 0000000..4f6c547 --- /dev/null +++ b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php @@ -0,0 +1,626 @@ +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); + $runner = new Queue([ + $this->commandResult( + output: $this->pages([ + [ + 'ref' => 'refs/tags/1.4.3', + 'object' => ['type' => 'commit', 'sha' => $first], + ], + [ + 'ref' => 'refs/tags/1.4.4', + 'object' => ['type' => 'commit', 'sha' => $second], + ], + ]), + ), + $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(); + + 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()[2]['command'][4], + ); + self::assertStringEndsWith( + '/releases/tags/1.4.4', + $runner->commands()[3]['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); + $repository = $this->repository(); + $repository->method('tags')->willReturn([ + new Tag('1.4.4', $released), + ]); + $repository->method('releases')->willReturn([ + new Recovery( + 9, + '1.4.4', + $released, + 0, + false, + false, + '', + ), + ]); + $repository->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, + ); + } + + 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."; + } +} From 92be74378502e30f6048535f5b9601ad40504ffa Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 16:53:54 +1200 Subject: [PATCH 11/19] (chore): establish PHP automation contracts --- .github/scripts/src/Automation/Candidate.php | 16 + .github/scripts/src/Automation/Clock.php | 12 + .../scripts/src/Automation/Clock/System.php | 19 + .github/scripts/src/Automation/Merge.php | 27 + .../scripts/src/Automation/MergeResult.php | 19 + .../scripts/src/Automation/Preparation.php | 16 + .../scripts/src/Automation/PullRequest.php | 19 + .github/scripts/src/Automation/Recovery.php | 19 + .github/scripts/src/Automation/Release.php | 14 + .github/scripts/src/Automation/Repository.php | 65 + .../scripts/src/Automation/ReviewDecision.php | 12 + .github/scripts/src/Automation/Run.php | 23 + .github/scripts/src/Automation/Sleeper.php | 10 + .../scripts/src/Automation/Sleeper/System.php | 19 + .github/scripts/src/Automation/Tag.php | 14 + .../scripts/src/Automation/WorkflowState.php | 15 + .github/scripts/src/Command/Exception.php | 38 + .github/scripts/src/Command/Process.php | 97 + .github/scripts/src/Command/Result.php | 20 + .github/scripts/src/Command/Runner.php | 13 + .github/scripts/src/Dependency/Fetcher.php | 10 + .github/scripts/src/Dependency/Source.php | 10 + .github/scripts/src/Dependency/Source/Git.php | 22 + .../scripts/src/Dependency/Source/PECL.php | 22 + .../scripts/tests/E2E/Command/ProcessTest.php | 64 + .../scripts/tests/Unit/Command/ResultTest.php | 19 + .../tests/Unit/Dependency/SourceTest.php | 33 + .gitignore | 3 + composer.json | 42 + composer.lock | 1837 +++++++++++++++++ phpstan.neon | 7 + phpunit.xml | 20 + pint.json | 16 + 33 files changed, 2592 insertions(+) create mode 100644 .github/scripts/src/Automation/Candidate.php create mode 100644 .github/scripts/src/Automation/Clock.php create mode 100644 .github/scripts/src/Automation/Clock/System.php create mode 100644 .github/scripts/src/Automation/Merge.php create mode 100644 .github/scripts/src/Automation/MergeResult.php create mode 100644 .github/scripts/src/Automation/Preparation.php create mode 100644 .github/scripts/src/Automation/PullRequest.php create mode 100644 .github/scripts/src/Automation/Recovery.php create mode 100644 .github/scripts/src/Automation/Release.php create mode 100644 .github/scripts/src/Automation/Repository.php create mode 100644 .github/scripts/src/Automation/ReviewDecision.php create mode 100644 .github/scripts/src/Automation/Run.php create mode 100644 .github/scripts/src/Automation/Sleeper.php create mode 100644 .github/scripts/src/Automation/Sleeper/System.php create mode 100644 .github/scripts/src/Automation/Tag.php create mode 100644 .github/scripts/src/Automation/WorkflowState.php create mode 100644 .github/scripts/src/Command/Exception.php create mode 100644 .github/scripts/src/Command/Process.php create mode 100644 .github/scripts/src/Command/Result.php create mode 100644 .github/scripts/src/Command/Runner.php create mode 100644 .github/scripts/src/Dependency/Fetcher.php create mode 100644 .github/scripts/src/Dependency/Source.php create mode 100644 .github/scripts/src/Dependency/Source/Git.php create mode 100644 .github/scripts/src/Dependency/Source/PECL.php create mode 100644 .github/scripts/tests/E2E/Command/ProcessTest.php create mode 100644 .github/scripts/tests/Unit/Command/ResultTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/SourceTest.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 pint.json diff --git a/.github/scripts/src/Automation/Candidate.php b/.github/scripts/src/Automation/Candidate.php new file mode 100644 index 0000000..4cd7ee1 --- /dev/null +++ b/.github/scripts/src/Automation/Candidate.php @@ -0,0 +1,16 @@ +'; + + /** + * @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/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 @@ + + */ + public function tags(): array; + + public function releaseByTag(string $tag): ?Recovery; + + /** + * @return list + */ + public function releases(): 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/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 @@ + 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 @@ + $command + */ + 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/Process.php b/.github/scripts/src/Command/Process.php new file mode 100644 index 0000000..bacdc64 --- /dev/null +++ b/.github/scripts/src/Command/Process.php @@ -0,0 +1,97 @@ +|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', + ); + } + + $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)) { + throw new Exception( + $command, + message: 'Unable to start command', + ); + } + + $code = proc_close($process); + rewind($output); + rewind($error); + $standardOutput = stream_get_contents($output); + $standardError = stream_get_contents($error); + + if ($standardOutput === false || $standardError === false) { + throw new Exception( + $command, + message: 'Unable to read command output', + ); + } + + $result = new Result($code, $standardOutput, $standardError); + 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/Fetcher.php b/.github/scripts/src/Dependency/Fetcher.php new file mode 100644 index 0000000..337467f --- /dev/null +++ b/.github/scripts/src/Dependency/Fetcher.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/tests/E2E/Command/ProcessTest.php b/.github/scripts/tests/E2E/Command/ProcessTest.php new file mode 100644 index 0000000..c4c5e5c --- /dev/null +++ b/.github/scripts/tests/E2E/Command/ProcessTest.php @@ -0,0 +1,64 @@ +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); + } + } +} diff --git a/.github/scripts/tests/Unit/Command/ResultTest.php b/.github/scripts/tests/Unit/Command/ResultTest.php new file mode 100644 index 0000000..dc67fcf --- /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/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/.gitignore b/.gitignore index 485dee6..0d4fde3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .idea +/.phpstan-cache/ +/.phpunit.cache/ +/vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fc23e49 --- /dev/null +++ b/composer.json @@ -0,0 +1,42 @@ +{ + "name": "appwrite/docker-base", + "description": "Tooling for Appwrite's shared PHP Docker base image", + "type": "project", + "license": "MIT", + "require": { + "php": ">=8.3" + }, + "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", + "format": "vendor/bin/pint", + "check": "vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G", + "test": "vendor/bin/phpunit --configuration=phpunit.xml", + "verify": [ + "@lint", + "@check", + "@test" + ] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..5ff9a4a --- /dev/null +++ b/composer.lock @@ -0,0 +1,1837 @@ +{ + "_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": "06137fbc2966a06bcc49b061cf62ff0b", + "packages": [], + "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": { + "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 + } +} From 8c1c48f79c76d7c50fba5689b52cd2516b90fb11 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 17:05:59 +1200 Subject: [PATCH 12/19] (feat): port dependency updater domain to PHP --- .../scripts/src/Dependency/Application.php | 58 +++ .github/scripts/src/Dependency/Catalog.php | 102 +++++ .github/scripts/src/Dependency/Change.php | 20 + .github/scripts/src/Dependency/Dependency.php | 15 + .github/scripts/src/Dependency/Dockerfile.php | 162 +++++++ .github/scripts/src/Dependency/Exception.php | 11 + .../scripts/src/Dependency/Fetcher/HTTP.php | 39 ++ .github/scripts/src/Dependency/Pin.php | 16 + .github/scripts/src/Dependency/Plan.php | 28 ++ .github/scripts/src/Dependency/Reporter.php | 38 ++ .github/scripts/src/Dependency/Resolver.php | 187 ++++++++ .github/scripts/src/Dependency/Selector.php | 54 +++ .github/scripts/src/Dependency/Updater.php | 37 ++ .github/scripts/src/Dependency/Version.php | 54 +++ .../tests/Unit/Dependency/CatalogTest.php | 121 +++++ .../tests/Unit/Dependency/Discovery.php | 99 ++++ .../tests/Unit/Dependency/DockerfileTest.php | 422 ++++++++++++++++++ .../scripts/tests/Unit/Dependency/Fixture.php | 117 +++++ .../tests/Unit/Dependency/ReporterTest.php | 92 ++++ .../tests/Unit/Dependency/ResolverTest.php | 83 ++++ .../tests/Unit/Dependency/VersionTest.php | 145 ++++++ 21 files changed, 1900 insertions(+) create mode 100644 .github/scripts/src/Dependency/Application.php create mode 100644 .github/scripts/src/Dependency/Catalog.php create mode 100644 .github/scripts/src/Dependency/Change.php create mode 100644 .github/scripts/src/Dependency/Dependency.php create mode 100644 .github/scripts/src/Dependency/Dockerfile.php create mode 100644 .github/scripts/src/Dependency/Exception.php create mode 100644 .github/scripts/src/Dependency/Fetcher/HTTP.php create mode 100644 .github/scripts/src/Dependency/Pin.php create mode 100644 .github/scripts/src/Dependency/Plan.php create mode 100644 .github/scripts/src/Dependency/Reporter.php create mode 100644 .github/scripts/src/Dependency/Resolver.php create mode 100644 .github/scripts/src/Dependency/Selector.php create mode 100644 .github/scripts/src/Dependency/Updater.php create mode 100644 .github/scripts/src/Dependency/Version.php create mode 100644 .github/scripts/tests/Unit/Dependency/CatalogTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/Discovery.php create mode 100644 .github/scripts/tests/Unit/Dependency/DockerfileTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/Fixture.php create mode 100644 .github/scripts/tests/Unit/Dependency/ReporterTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/ResolverTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/VersionTest.php 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/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/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..7d55218 --- /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/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/Version.php b/.github/scripts/src/Dependency/Version.php new file mode 100644 index 0000000..48b0d35 --- /dev/null +++ b/.github/scripts/src/Dependency/Version.php @@ -0,0 +1,54 @@ +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/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/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/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/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']); + } +} From c0389713dbdb494bfb793d35fbe2e0410d02b9fa Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 17:22:38 +1200 Subject: [PATCH 13/19] (refactor): replace dependency automation with PHP --- .github/scripts/automation/__init__.py | 71 -- .../automation/approval_missing_error.py | 5 - .../scripts/automation/automation_error.py | 2 - .github/scripts/automation/candidate.py | 11 - .github/scripts/automation/deadline.py | 38 - .../scripts/automation/head_changed_error.py | 5 - .github/scripts/automation/merge.py | 53 -- .github/scripts/automation/merge_result.py | 39 - .github/scripts/automation/pull_request.py | 44 - .../pull_request_unavailable_error.py | 5 - .github/scripts/automation/recovery.py | 144 --- .github/scripts/automation/recovery_error.py | 5 - .github/scripts/automation/release.py | 23 - .github/scripts/automation/review_decision.py | 9 - .github/scripts/automation/run.py | 96 -- .github/scripts/automation/tag.py | 23 - .../automation/target_mismatch_error.py | 5 - .github/scripts/automation/version.py | 115 --- .../automation/version_invalid_error.py | 5 - .../automation/version_missing_error.py | 5 - .github/scripts/automation/workflow_state.py | 12 - .github/scripts/bin/dependencies.php | 28 + .github/scripts/bin/orchestrator.php | 45 + .github/scripts/dependencies.py | 473 ---------- .github/scripts/dependency/Change.py | 18 - .github/scripts/dependency/CommandRunner.py | 14 - .github/scripts/dependency/Dependency.py | 16 - .github/scripts/dependency/Fetcher.py | 14 - .github/scripts/dependency/GitSource.py | 12 - .github/scripts/dependency/PeclSource.py | 12 - .github/scripts/dependency/Pin.py | 15 - .github/scripts/dependency/Plan.py | 19 - .github/scripts/dependency/Source.py | 9 - .github/scripts/dependency/UpdateError.py | 5 - .github/scripts/dependency/Version.py | 14 - .github/scripts/dependency/__init__.py | 1 - .github/scripts/orchestrator.py | 874 ------------------ .../scripts/src/Automation/Application.php | 27 +- .../scripts/src/Automation/Orchestrator.php | 93 ++ .../scripts/src/Automation/Pull/Payload.php | 64 ++ .github/scripts/src/Dependency/Console.php | 63 ++ .github/scripts/test_automation.py | 656 ------------- .github/scripts/test_dependencies.py | 758 --------------- .github/scripts/test_orchestrator.py | 500 ---------- .../scripts/tests/Unit/ArchitectureTest.php | 163 ++++ .../Unit/Automation/OrchestratorTest.php | 86 +- .../tests/Unit/Automation/PayloadTest.php | 105 +++ .../tests/Unit/Dependency/ConsoleTest.php | 78 ++ .../tests/Unit/Dependency/ParityTest.php | 146 +++ .github/scripts/tests/equivalence.json | 744 +++++++++++++++ .github/workflows/dependencies.yml | 230 ++--- 51 files changed, 1694 insertions(+), 4303 deletions(-) delete mode 100644 .github/scripts/automation/__init__.py delete mode 100644 .github/scripts/automation/approval_missing_error.py delete mode 100644 .github/scripts/automation/automation_error.py delete mode 100644 .github/scripts/automation/candidate.py delete mode 100644 .github/scripts/automation/deadline.py delete mode 100644 .github/scripts/automation/head_changed_error.py delete mode 100644 .github/scripts/automation/merge.py delete mode 100644 .github/scripts/automation/merge_result.py delete mode 100644 .github/scripts/automation/pull_request.py delete mode 100644 .github/scripts/automation/pull_request_unavailable_error.py delete mode 100644 .github/scripts/automation/recovery.py delete mode 100644 .github/scripts/automation/recovery_error.py delete mode 100644 .github/scripts/automation/release.py delete mode 100644 .github/scripts/automation/review_decision.py delete mode 100644 .github/scripts/automation/run.py delete mode 100644 .github/scripts/automation/tag.py delete mode 100644 .github/scripts/automation/target_mismatch_error.py delete mode 100644 .github/scripts/automation/version.py delete mode 100644 .github/scripts/automation/version_invalid_error.py delete mode 100644 .github/scripts/automation/version_missing_error.py delete mode 100644 .github/scripts/automation/workflow_state.py create mode 100755 .github/scripts/bin/dependencies.php create mode 100755 .github/scripts/bin/orchestrator.php delete mode 100644 .github/scripts/dependencies.py delete mode 100644 .github/scripts/dependency/Change.py delete mode 100644 .github/scripts/dependency/CommandRunner.py delete mode 100644 .github/scripts/dependency/Dependency.py delete mode 100644 .github/scripts/dependency/Fetcher.py delete mode 100644 .github/scripts/dependency/GitSource.py delete mode 100644 .github/scripts/dependency/PeclSource.py delete mode 100644 .github/scripts/dependency/Pin.py delete mode 100644 .github/scripts/dependency/Plan.py delete mode 100644 .github/scripts/dependency/Source.py delete mode 100644 .github/scripts/dependency/UpdateError.py delete mode 100644 .github/scripts/dependency/Version.py delete mode 100644 .github/scripts/dependency/__init__.py delete mode 100644 .github/scripts/orchestrator.py create mode 100644 .github/scripts/src/Automation/Pull/Payload.php create mode 100644 .github/scripts/src/Dependency/Console.php delete mode 100644 .github/scripts/test_automation.py delete mode 100644 .github/scripts/test_dependencies.py delete mode 100644 .github/scripts/test_orchestrator.py create mode 100644 .github/scripts/tests/Unit/ArchitectureTest.php create mode 100644 .github/scripts/tests/Unit/Automation/PayloadTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/ConsoleTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/ParityTest.php create mode 100644 .github/scripts/tests/equivalence.json diff --git a/.github/scripts/automation/__init__.py b/.github/scripts/automation/__init__.py deleted file mode 100644 index 4000fb5..0000000 --- a/.github/scripts/automation/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Typed orchestration rules for dependency updates and patch releases.""" - -from automation.approval_missing_error import ApprovalMissingError -from automation.automation_error import AutomationError -from automation.candidate import Candidate -from automation.deadline import Deadline -from automation.head_changed_error import HeadChangedError -from automation.merge import Merge -from automation.merge_result import MergeResult -from automation.pull_request import PullRequest -from automation.pull_request_unavailable_error import ( - PullRequestUnavailableError, -) -from automation.recovery import Recovery -from automation.recovery_error import RecoveryError -from automation.release import Release -from automation.review_decision import ReviewDecision -from automation.run import Run -from automation.tag import Tag -from automation.target_mismatch_error import TargetMismatchError -from automation.version import Version -from automation.version_invalid_error import VersionInvalidError -from automation.version_missing_error import VersionMissingError -from automation.workflow_state import WorkflowState - - -latest_version = Version.latest -newer_unreleased_tag = Version.unreleased -next_patch = Version.next -patch_after_collision = Version.after_collision -release_candidate = Version.candidate -select_run = Run.select -stable_versions = Version.stable -validate_pull_request = PullRequest.validate -validate_release_target = Release.validate -validate_tag_target = Tag.validate -workflow_state = Run.workflow_state - -__all__ = ( - 'ApprovalMissingError', - 'AutomationError', - 'Candidate', - 'Deadline', - 'HeadChangedError', - 'Merge', - 'MergeResult', - 'PullRequest', - 'PullRequestUnavailableError', - 'Recovery', - 'RecoveryError', - 'Release', - 'ReviewDecision', - 'Run', - 'Tag', - 'TargetMismatchError', - 'Version', - 'VersionInvalidError', - 'VersionMissingError', - 'WorkflowState', - 'latest_version', - 'newer_unreleased_tag', - 'next_patch', - 'patch_after_collision', - 'release_candidate', - 'select_run', - 'stable_versions', - 'validate_pull_request', - 'validate_release_target', - 'validate_tag_target', - 'workflow_state', -) diff --git a/.github/scripts/automation/approval_missing_error.py b/.github/scripts/automation/approval_missing_error.py deleted file mode 100644 index a2b9427..0000000 --- a/.github/scripts/automation/approval_missing_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from automation.automation_error import AutomationError - - -class ApprovalMissingError(AutomationError): - """Raised when a pull request does not currently have approval.""" diff --git a/.github/scripts/automation/automation_error.py b/.github/scripts/automation/automation_error.py deleted file mode 100644 index 13744c1..0000000 --- a/.github/scripts/automation/automation_error.py +++ /dev/null @@ -1,2 +0,0 @@ -class AutomationError(RuntimeError): - """Base error for a failed orchestration invariant.""" diff --git a/.github/scripts/automation/candidate.py b/.github/scripts/automation/candidate.py deleted file mode 100644 index 5f36109..0000000 --- a/.github/scripts/automation/candidate.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Candidate: - """A uniquely recoverable dependency release.""" - - tag: str | None - target: str - pull: int - draft: int | None diff --git a/.github/scripts/automation/deadline.py b/.github/scripts/automation/deadline.py deleted file mode 100644 index 76ef357..0000000 --- a/.github/scripts/automation/deadline.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timedelta - - -@dataclass(frozen=True) -class Deadline: - """An injected absolute deadline used without sleeping.""" - - at: datetime - - def __post_init__(self) -> None: - self.require_aware(self.at, 'Deadline') - - @staticmethod - def require_aware(value: datetime, name: str) -> None: - """Require an offset-aware timestamp.""" - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError(f'{name} must include a timezone') - - @classmethod - def after(cls, now: datetime, timeout: timedelta) -> Deadline: - """Create a deadline relative to injected current time.""" - cls.require_aware(now, 'Current time') - if timeout <= timedelta(): - raise ValueError('Timeout must be positive') - return cls(now + timeout) - - def expired(self, now: datetime) -> bool: - """Return whether the deadline has been reached.""" - self.require_aware(now, 'Current time') - return now >= self.at - - def remaining(self, now: datetime) -> timedelta: - """Return remaining time, clamped at zero.""" - self.require_aware(now, 'Current time') - return max(self.at - now, timedelta()) diff --git a/.github/scripts/automation/head_changed_error.py b/.github/scripts/automation/head_changed_error.py deleted file mode 100644 index 77f6448..0000000 --- a/.github/scripts/automation/head_changed_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from automation.automation_error import AutomationError - - -class HeadChangedError(AutomationError): - """Raised when a pull request no longer points to an approved ref.""" diff --git a/.github/scripts/automation/merge.py b/.github/scripts/automation/merge.py deleted file mode 100644 index 2cddf3f..0000000 --- a/.github/scripts/automation/merge.py +++ /dev/null @@ -1,53 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Merge: - """Repository evidence for a merged dependency automation pull request.""" - - marker = '' - - number: int - target: str - head: str - parents: tuple[str, ...] - base: str - branch: str - body: str - files: tuple[str, ...] - state: str - - def is_automation(self) -> bool: - """Return whether all immutable automation provenance facts match.""" - lines = self.body.splitlines() - head = f'' - parent = ( - f'' - if len(self.parents) == 1 - else '' - ) - return ( - self.state == 'merged' - and self.base == 'main' - and self.branch.startswith('automation/dependencies-') - and lines.count(self.marker) == 1 - and lines.count(head) == 1 - and sum( - line.startswith('' - - identifier: int - tag: str - target: str - pull: int - draft: bool - prerelease: bool - body: str - - def matches(self, merge: Merge) -> bool: - """Return whether this draft records the exact merge provenance.""" - lines = self.body.splitlines() - return ( - self.draft - and not self.prerelease - and lines.count(self.marker) == 1 - and lines.count( - f'' - ) == 1 - and lines.count( - f'' - ) == 1 - and self.pull in {0, merge.number} - and self.target == merge.target - ) - - @classmethod - def select( - cls, - tags: Sequence[Tag], - releases: Sequence[Recovery], - merges: Sequence[Merge], - ) -> Candidate | None: - """Select one qualified unpublished merge or fail closed.""" - released = { - release.tag - for release in releases - if not release.draft - } - stable_published = Version.stable( - release.tag - for release in releases - if not release.draft and not release.prerelease - ) - threshold = stable_published[-1] if stable_published else None - candidates: list[Candidate] = [] - targets = {tag.target for tag in tags} - - for tag in tags: - version = Version.parse(tag.name) - if ( - version is None - or tag.name in released - or (threshold is not None and version <= threshold) - ): - continue - - evidence = [ - merge - for merge in merges - if merge.target == tag.target and merge.is_automation() - ] - if len(evidence) != 1: - continue - - merge = evidence[0] - drafts = [ - release - for release in releases - if release.tag == tag.name and release.matches(merge) - ] - if len(drafts) > 1: - raise RecoveryError( - f'Multiple automation drafts exist for {tag.name}' - ) - candidates.append( - Candidate( - tag=tag.name, - target=tag.target, - pull=merge.number, - draft=drafts[0].identifier if drafts else None, - ) - ) - - candidates.extend( - Candidate( - tag=None, - target=merge.target, - pull=merge.number, - draft=None, - ) - for merge in merges - if merge.target not in targets and merge.is_automation() - ) - - unique = { - (candidate.tag, candidate.target, candidate.pull, candidate.draft): - candidate - for candidate in candidates - } - if len(unique) > 1: - names = ', '.join( - sorted( - candidate.tag or f'pull request #{candidate.pull}' - for candidate in unique.values() - ) - ) - raise RecoveryError( - f'Multiple dependency releases are recoverable: {names}' - ) - candidate = next(iter(unique.values()), None) - marked = [ - release - for release in releases - if release.draft and cls.marker in release.body - ] - if marked and ( - candidate is None - or any( - release.identifier != candidate.draft - for release in marked - ) - ): - raise RecoveryError( - 'Unsafe dependency automation draft state exists' - ) - return candidate diff --git a/.github/scripts/automation/recovery_error.py b/.github/scripts/automation/recovery_error.py deleted file mode 100644 index d667866..0000000 --- a/.github/scripts/automation/recovery_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from automation.automation_error import AutomationError - - -class RecoveryError(AutomationError): - """Raised when release recovery state is unsafe or ambiguous.""" diff --git a/.github/scripts/automation/release.py b/.github/scripts/automation/release.py deleted file mode 100644 index 407f4a2..0000000 --- a/.github/scripts/automation/release.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass - -from automation.target_mismatch_error import TargetMismatchError - - -@dataclass(frozen=True) -class Release: - """A release and the resolved target of its associated tag.""" - - tag: str - target: str - - def validate(self, *, expected_tag: str, expected_target: str) -> None: - """Require the expected tag and exact target commit.""" - if self.tag != expected_tag: - raise TargetMismatchError( - f'Expected release for {expected_tag}, found {self.tag}' - ) - if self.target != expected_target: - raise TargetMismatchError( - f'Release {self.tag} targets {self.target}, ' - f'expected {expected_target}' - ) diff --git a/.github/scripts/automation/review_decision.py b/.github/scripts/automation/review_decision.py deleted file mode 100644 index 42a5c75..0000000 --- a/.github/scripts/automation/review_decision.py +++ /dev/null @@ -1,9 +0,0 @@ -from enum import Enum - - -class ReviewDecision(Enum): - """Normalized current pull request review decision.""" - - APPROVED = 'approved' - CHANGES_REQUESTED = 'changes_requested' - REVIEW_REQUIRED = 'review_required' diff --git a/.github/scripts/automation/run.py b/.github/scripts/automation/run.py deleted file mode 100644 index aed7c97..0000000 --- a/.github/scripts/automation/run.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime -from typing import Sequence - -from automation.deadline import Deadline -from automation.workflow_state import WorkflowState - - -@dataclass(frozen=True) -class Run: - """Normalized workflow run state supplied by a GitHub adapter.""" - - identifier: int - workflow: str - event: str - head: str - branch: str - created: datetime - attempt: int - status: str - conclusion: str | None - - def __post_init__(self) -> None: - Deadline.require_aware(self.created, 'Workflow run creation time') - if self.attempt < 1: - raise ValueError('Workflow run attempt must be positive') - - @classmethod - def select( - cls, - runs: Sequence[Run], - *, - workflow: str, - event: str, - head: str, - branch: str, - created: datetime, - ) -> Run | None: - """Select the newest exact run and its newest rerun attempt.""" - Deadline.require_aware(created, 'Workflow run boundary') - matches = [ - run - for run in runs - if run.workflow == workflow - and run.event == event - and run.head == head - and run.branch == branch - and run.created >= created - ] - return max( - matches, - key=lambda run: (run.created, run.identifier, run.attempt), - default=None, - ) - - @classmethod - def workflow_state( - cls, - runs: Sequence[Run], - *, - workflow: str, - event: str, - head: str, - branch: str, - created: datetime, - deadline: Deadline, - now: datetime, - ) -> WorkflowState: - """Evaluate one exact workflow run without polling or sleeping.""" - run = cls.select( - runs, - workflow=workflow, - event=event, - head=head, - branch=branch, - created=created, - ) - if run is None: - if deadline.expired(now): - return WorkflowState.TIMED_OUT - return WorkflowState.MISSING - - if run.status != 'completed': - if deadline.expired(now): - return WorkflowState.TIMED_OUT - return WorkflowState.PENDING - - if run.conclusion == 'success': - return WorkflowState.SUCCEEDED - if run.conclusion == 'cancelled': - return WorkflowState.CANCELLED - if run.conclusion == 'timed_out': - return WorkflowState.TIMED_OUT - return WorkflowState.FAILED diff --git a/.github/scripts/automation/tag.py b/.github/scripts/automation/tag.py deleted file mode 100644 index bd0467a..0000000 --- a/.github/scripts/automation/tag.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass - -from automation.target_mismatch_error import TargetMismatchError - - -@dataclass(frozen=True) -class Tag: - """A remote tag resolved to its target commit.""" - - name: str - target: str - - def validate(self, *, expected_name: str, expected_target: str) -> None: - """Require the expected name and exact target commit.""" - if self.name != expected_name: - raise TargetMismatchError( - f'Expected tag {expected_name}, found {self.name}' - ) - if self.target != expected_target: - raise TargetMismatchError( - f'Tag {self.name} targets {self.target}, ' - f'expected {expected_target}' - ) diff --git a/.github/scripts/automation/target_mismatch_error.py b/.github/scripts/automation/target_mismatch_error.py deleted file mode 100644 index 962ba7b..0000000 --- a/.github/scripts/automation/target_mismatch_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from automation.automation_error import AutomationError - - -class TargetMismatchError(AutomationError): - """Raised when a tag or release points at an unexpected commit.""" diff --git a/.github/scripts/automation/version.py b/.github/scripts/automation/version.py deleted file mode 100644 index dfe1256..0000000 --- a/.github/scripts/automation/version.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass -from typing import ClassVar, Iterable, Pattern - -from automation.version_invalid_error import VersionInvalidError -from automation.version_missing_error import VersionMissingError - - -@dataclass(frozen=True, order=True) -class Version: - """An unprefixed stable MAJOR.MINOR.PATCH version.""" - - pattern: ClassVar[Pattern[str]] = re.compile( - r'(?P0|[1-9][0-9]*)\.' - r'(?P0|[1-9][0-9]*)\.' - r'(?P0|[1-9][0-9]*)' - ) - - major: int - minor: int - patch: int - - @classmethod - def parse(cls, value: str) -> Version | None: - """Parse a stable version, returning None for unsupported tag names.""" - match = cls.pattern.fullmatch(value) - if match is None: - return None - - try: - return cls( - major=int(match.group('major')), - minor=int(match.group('minor')), - patch=int(match.group('patch')), - ) - except ValueError: - return None - - @classmethod - def stable(cls, tags: Iterable[str]) -> tuple[Version, ...]: - """Return unique stable versions in semantic order.""" - versions = { - version - for tag in tags - if (version := cls.parse(tag)) is not None - } - return tuple(sorted(versions)) - - @classmethod - def latest(cls, tags: Iterable[str]) -> Version: - """Return the semantic maximum across all supplied remote tags.""" - versions = cls.stable(tags) - if not versions: - raise VersionMissingError('No stable remote version tag exists') - return versions[-1] - - @classmethod - def next(cls, tags: Iterable[str]) -> Version: - """Compute the next patch from the semantic maximum remote tag.""" - return cls.latest(tags).next_patch() - - @classmethod - def unreleased( - cls, - tags: Iterable[str], - releases: Iterable[str], - ) -> Version | None: - """Find the newest remote tag newer than every published release.""" - tagged = cls.stable(tags) - if not tagged: - return None - - published = cls.stable(releases) - threshold = published[-1] if published else None - candidates = [ - version - for version in tagged - if version not in published - and (threshold is None or version > threshold) - ] - return max(candidates, default=None) - - @classmethod - def candidate( - cls, - tags: Iterable[str], - releases: Iterable[str], - ) -> Version: - """Resume an unreleased newer tag or compute a fresh patch version.""" - tags = tuple(tags) - unreleased = cls.unreleased(tags, releases) - return unreleased if unreleased is not None else cls.next(tags) - - @classmethod - def after_collision( - cls, - tags: Iterable[str], - collision: str, - ) -> Version: - """Recompute a patch after refreshing tags following a collision.""" - collided = cls.parse(collision) - if collided is None: - raise VersionInvalidError( - f'Collision tag {collision!r} is not a stable version' - ) - return cls.next((*tags, str(collided))) - - def next_patch(self) -> Version: - """Return the immediately following patch version.""" - return Version(self.major, self.minor, self.patch + 1) - - def __str__(self) -> str: - return f'{self.major}.{self.minor}.{self.patch}' diff --git a/.github/scripts/automation/version_invalid_error.py b/.github/scripts/automation/version_invalid_error.py deleted file mode 100644 index 2ca6d9b..0000000 --- a/.github/scripts/automation/version_invalid_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from automation.automation_error import AutomationError - - -class VersionInvalidError(AutomationError): - """Raised when a required value is not a stable version.""" diff --git a/.github/scripts/automation/version_missing_error.py b/.github/scripts/automation/version_missing_error.py deleted file mode 100644 index 2eacbc0..0000000 --- a/.github/scripts/automation/version_missing_error.py +++ /dev/null @@ -1,5 +0,0 @@ -from automation.automation_error import AutomationError - - -class VersionMissingError(AutomationError): - """Raised when no stable version tag exists.""" diff --git a/.github/scripts/automation/workflow_state.py b/.github/scripts/automation/workflow_state.py deleted file mode 100644 index b0ccf65..0000000 --- a/.github/scripts/automation/workflow_state.py +++ /dev/null @@ -1,12 +0,0 @@ -from enum import Enum - - -class WorkflowState(Enum): - """A closed set of states used by polling orchestration.""" - - MISSING = 'missing' - PENDING = 'pending' - SUCCEEDED = 'succeeded' - FAILED = 'failed' - CANCELLED = 'cancelled' - TIMED_OUT = 'timed_out' diff --git a/.github/scripts/bin/dependencies.php b/.github/scripts/bin/dependencies.php new file mode 100755 index 0000000..f0e9dc9 --- /dev/null +++ b/.github/scripts/bin/dependencies.php @@ -0,0 +1,28 @@ +#!/usr/bin/env php +execute(array_slice($argv, 1)) . PHP_EOL); diff --git a/.github/scripts/bin/orchestrator.php b/.github/scripts/bin/orchestrator.php new file mode 100755 index 0000000..e07a933 --- /dev/null +++ b/.github/scripts/bin/orchestrator.php @@ -0,0 +1,45 @@ +#!/usr/bin/env php +execute(array_slice($argv, 1), $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/dependencies.py b/.github/scripts/dependencies.py deleted file mode 100644 index baa415b..0000000 --- a/.github/scripts/dependencies.py +++ /dev/null @@ -1,473 +0,0 @@ -#!/usr/bin/env python3 -"""Update the Dockerfile's pinned base image and PHP extension releases.""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -import urllib.request -import xml.etree.ElementTree as ElementTree -from collections.abc import Iterable, Sequence -from pathlib import Path - -from dependency.Change import Change -from dependency.CommandRunner import CommandRunner -from dependency.Dependency import Dependency -from dependency.Fetcher import Fetcher -from dependency.GitSource import GitSource -from dependency.PeclSource import PeclSource -from dependency.Pin import Pin -from dependency.Plan import Plan -from dependency.Source import Source -from dependency.UpdateError import UpdateError -from dependency.Version import Version - - -BASE_NAME = 'php:8.5-alpine' -BASE_PATTERN = re.compile( - rf'{re.escape(BASE_NAME)}@(sha256:[0-9a-f]{{64}})' -) -DECLARATION_LINE_PATTERN = re.compile( - r'(?m)^[ \t]*(?:(?:ARG|ENV)[ \t]+[^\r\n]*|' - r'PHP_[A-Za-z0-9_]+_VERSION[^\r\n]*)$' -) -DECLARATION_PATTERN = re.compile( - r'(? Version | None: - """Parse only an exact stable v?MAJOR.MINOR.PATCH spelling.""" - - match = VERSION_PATTERN.fullmatch(spelling) - if match is None: - return None - return Version(*(int(part) for part in match.groups())) - - -def select_version(current: str, releases: Iterable[str]) -> str: - """Select the newest non-downgrading release in the current major.""" - - current_version = parse_version(current) - if current_version is None: - raise UpdateError(f'Invalid current version: {current}') - - parsed = tuple( - (version, release) - for release in releases - if (version := parse_version(release)) is not None - and version.major == current_version.major - and version > current_version - ) - if not parsed: - return current - - latest_version = max(version for version, _ in parsed) - spellings = tuple( - release - for version, release in parsed - if version == latest_version - ) - current_has_prefix = current.startswith('v') - matching = tuple( - spelling - for spelling in spellings - if spelling.startswith('v') == current_has_prefix - ) - return min(matching or spellings) - - -def parse_git_tags(output: str) -> tuple[str, ...]: - """Extract exact tag names from git ls-remote output.""" - - prefix = 'refs/tags/' - tags: list[str] = [] - for line in output.splitlines(): - fields = line.split() - if len(fields) != 2 or not fields[1].startswith(prefix): - continue - tag = fields[1][len(prefix) :] - if parse_version(tag) is not None: - tags.append(tag) - return tuple(tags) - - -def parse_pecl_releases(document: bytes) -> tuple[str, ...]: - """Extract exact stable releases from a PECL allreleases document.""" - - try: - root = ElementTree.fromstring(document) - except ElementTree.ParseError as error: - raise UpdateError(f'Invalid PECL release XML: {error}') from error - - releases: list[str] = [] - for release in root: - if _local_name(release.tag) != 'r': - continue - fields = { - _local_name(child.tag): (child.text or '').strip() - for child in release - } - spelling = fields.get('v', '') - if ( - fields.get('s', '').lower() == 'stable' - and parse_version(spelling) is not None - ): - releases.append(spelling) - return tuple(releases) - - -def _local_name(name: str) -> str: - return name.rsplit('}', 1)[-1] - - -def run_command(command: tuple[str, ...]) -> str: - """Run one discovery command and return stdout.""" - - try: - completed = subprocess.run( - command, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - except (OSError, subprocess.CalledProcessError) as error: - detail = getattr(error, 'stderr', '') or str(error) - raise UpdateError( - f'Command failed: {" ".join(command)}: {detail.strip()}' - ) from error - return completed.stdout - - -def fetch(url: str) -> bytes: - """Fetch one authoritative release feed.""" - - try: - with urllib.request.urlopen(url, timeout=30) as response: - return response.read() - except OSError as error: - raise UpdateError(f'Failed to fetch {url}: {error}') from error - - -def resolve_digest(runner: CommandRunner = run_command) -> str: - """Resolve and validate the base image's multi-architecture digest.""" - - output = runner( - ('docker', 'buildx', 'imagetools', 'inspect', BASE_NAME) - ) - matches = tuple( - match.group(1) - for match in re.finditer( - r'(?m)^Digest:[ \t]*(sha256:[0-9a-f]{64})[ \t]*$', - output, - ) - ) - if len(matches) != 1 or DIGEST_PATTERN.fullmatch(matches[0]) is None: - raise UpdateError( - f'Expected one lowercase sha256 digest for {BASE_NAME}' - ) - return matches[0] - - -def discover_releases( - dependency: Dependency, - runner: CommandRunner = run_command, - fetcher: Fetcher = fetch, -) -> tuple[str, ...]: - """Read stable release spellings from the configured typed source.""" - - if isinstance(dependency.source, GitSource): - output = runner( - ( - 'git', - 'ls-remote', - '--tags', - '--refs', - dependency.source.url, - ) - ) - releases = parse_git_tags(output) - else: - releases = parse_pecl_releases(fetcher(dependency.source.url)) - - if not releases: - raise UpdateError( - f'No exact stable releases found for {dependency.name}' - ) - return releases - - -def read_pins(content: str) -> tuple[Pin, ...]: - """Validate and locate every expected Dockerfile declaration once.""" - - expected = {dependency.variable for dependency in DEPENDENCIES} - declared = { - match.group(1) - for line in DECLARATION_LINE_PATTERN.finditer(content) - for match in DECLARATION_PATTERN.finditer(line.group(0)) - } - unknown = tuple(sorted(declared - expected)) - if unknown: - raise UpdateError( - 'Unknown PHP extension version ' - f'declaration{"s" if len(unknown) != 1 else ""}: ' - f'{", ".join(unknown)}' - ) - - image_expression = re.compile( - r'(?m)^[ \t]*ARG[ \t]+BASE_IMAGE="([^"\r\n]+)"[ \t]*$' - ) - image_match = _single_match( - image_expression, - content, - 'ARG BASE_IMAGE', - ) - image_value = image_match.group(1) - value_match = BASE_PATTERN.fullmatch(image_value) - if value_match is None: - raise UpdateError( - f'ARG BASE_IMAGE must pin {BASE_NAME} to a lowercase sha256 digest' - ) - digest_start = image_match.start(1) + value_match.start(1) - pins: list[Pin] = [ - Pin( - BASE_NAME, - value_match.group(1), - digest_start, - digest_start + len(value_match.group(1)), - ) - ] - - for dependency in DEPENDENCIES: - expression = re.compile( - rf'(?m)^[ \t]*(?:ENV[ \t]+)?' - rf'{re.escape(dependency.variable)}=' - r'"([^"\r\n]+)"[ \t]*(?:\\)?[ \t]*$' - ) - match = _single_match( - expression, - content, - dependency.variable, - ) - current = match.group(1) - if parse_version(current) is None: - raise UpdateError( - f'{dependency.variable} must be an exact stable ' - 'v?MAJOR.MINOR.PATCH version' - ) - pins.append( - Pin( - dependency.name, - current, - match.start(1), - match.end(1), - ) - ) - - return tuple(pins) - - -def _single_match( - expression: re.Pattern[str], - content: str, - declaration: str, -) -> re.Match[str]: - matches = tuple(expression.finditer(content)) - if len(matches) != 1: - raise UpdateError( - f'Expected exactly one {declaration} declaration, ' - f'found {len(matches)}' - ) - return matches[0] - - -def plan_update( - content: str, - runner: CommandRunner = run_command, - fetcher: Fetcher = fetch, -) -> Plan: - """Resolve all releases before constructing an in-memory update.""" - - pins = read_pins(content) - latest = [resolve_digest(runner)] - for dependency, pin in zip(DEPENDENCIES, pins[1:], strict=True): - releases = discover_releases(dependency, runner, fetcher) - latest.append(select_version(pin.current, releases)) - - changes = tuple( - Change(pin.name, pin.current, selected) - for pin, selected in zip(pins, latest, strict=True) - ) - updated = content - for pin, selected in reversed( - tuple(zip(pins, latest, strict=True)) - ): - updated = updated[: pin.start] + selected + updated[pin.end :] - return Plan(updated, changes) - - -def render_report(plan: Plan) -> str: - """Render the complete update result as Markdown.""" - - changed = sum(change.changed for change in plan.changes) - rows = [ - '## Dependency update report', - '', - '| Dependency | Current | Selected | Result |', - '| --- | --- | --- | --- |', - ] - rows.extend( - '| {name} | `{current}` | `{latest}` | {result} |'.format( - name=change.name, - current=change.current, - latest=change.latest, - result='Updated' if change.changed else 'Current', - ) - for change in plan.changes - ) - rows.extend( - ( - '', - f'**Updates:** {changed}', - '', - ( - 'Dockerfile pins were updated.' - if changed - else 'No dependency updates were found.' - ), - ) - ) - return '\n'.join(rows) - - -def update( - dockerfile: Path, - *, - dry_run: bool = False, - runner: CommandRunner = run_command, - fetcher: Fetcher = fetch, -) -> Plan: - """Plan updates and optionally mutate only the requested Dockerfile.""" - - if dockerfile.name != 'Dockerfile': - raise UpdateError('The update target must be named Dockerfile') - content = dockerfile.read_text(encoding='utf-8') - plan = plan_update(content, runner, fetcher) - if plan.changed and not dry_run: - dockerfile.write_text(plan.content, encoding='utf-8') - return plan - - -def main(arguments: Sequence[str] | None = None) -> int: - """Run the dependency updater.""" - - root = Path(__file__).resolve().parents[2] - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - '--dockerfile', - type=Path, - default=root / 'Dockerfile', - help='Dockerfile to inspect and update', - ) - parser.add_argument( - '--dry-run', - action='store_true', - help='report updates without modifying the Dockerfile', - ) - options = parser.parse_args(arguments) - - try: - plan = update( - options.dockerfile, - dry_run=options.dry_run, - ) - except (OSError, UpdateError) as error: - print(f'Error: {error}', file=sys.stderr) - return 1 - print(render_report(plan)) - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/.github/scripts/dependency/Change.py b/.github/scripts/dependency/Change.py deleted file mode 100644 index 5e97ea9..0000000 --- a/.github/scripts/dependency/Change.py +++ /dev/null @@ -1,18 +0,0 @@ -"""A dependency update selection.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class Change: - """The current and selected value for one dependency.""" - - name: str - current: str - latest: str - - @property - def changed(self) -> bool: - return self.current != self.latest diff --git a/.github/scripts/dependency/CommandRunner.py b/.github/scripts/dependency/CommandRunner.py deleted file mode 100644 index 1676900..0000000 --- a/.github/scripts/dependency/CommandRunner.py +++ /dev/null @@ -1,14 +0,0 @@ -"""A dependency discovery command runner.""" - -from __future__ import annotations - -from typing import Protocol - - -class CommandRunner(Protocol): - """Execute one dependency discovery command.""" - - def __call__(self, command: tuple[str, ...]) -> str: - """Return the command's standard output.""" - - ... diff --git a/.github/scripts/dependency/Dependency.py b/.github/scripts/dependency/Dependency.py deleted file mode 100644 index 06f260c..0000000 --- a/.github/scripts/dependency/Dependency.py +++ /dev/null @@ -1,16 +0,0 @@ -"""A pinned Dockerfile dependency.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from dependency.Source import Source - - -@dataclass(frozen=True, slots=True) -class Dependency: - """A Dockerfile variable and its authoritative release source.""" - - name: str - variable: str - source: Source diff --git a/.github/scripts/dependency/Fetcher.py b/.github/scripts/dependency/Fetcher.py deleted file mode 100644 index 95abfe6..0000000 --- a/.github/scripts/dependency/Fetcher.py +++ /dev/null @@ -1,14 +0,0 @@ -"""A dependency release feed fetcher.""" - -from __future__ import annotations - -from typing import Protocol - - -class Fetcher(Protocol): - """Fetch one authoritative dependency release feed.""" - - def __call__(self, url: str) -> bytes: - """Return the response body.""" - - ... diff --git a/.github/scripts/dependency/GitSource.py b/.github/scripts/dependency/GitSource.py deleted file mode 100644 index edf9146..0000000 --- a/.github/scripts/dependency/GitSource.py +++ /dev/null @@ -1,12 +0,0 @@ -"""A git release source.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class GitSource: - """A git repository whose exact version tags are release candidates.""" - - url: str diff --git a/.github/scripts/dependency/PeclSource.py b/.github/scripts/dependency/PeclSource.py deleted file mode 100644 index 3ba0815..0000000 --- a/.github/scripts/dependency/PeclSource.py +++ /dev/null @@ -1,12 +0,0 @@ -"""A PECL release source.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class PeclSource: - """A PECL stable-release feed.""" - - url: str diff --git a/.github/scripts/dependency/Pin.py b/.github/scripts/dependency/Pin.py deleted file mode 100644 index 4dfd01f..0000000 --- a/.github/scripts/dependency/Pin.py +++ /dev/null @@ -1,15 +0,0 @@ -"""A located Dockerfile dependency pin.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class Pin: - """An exact declaration location in the Dockerfile.""" - - name: str - current: str - start: int - end: int diff --git a/.github/scripts/dependency/Plan.py b/.github/scripts/dependency/Plan.py deleted file mode 100644 index 7319533..0000000 --- a/.github/scripts/dependency/Plan.py +++ /dev/null @@ -1,19 +0,0 @@ -"""A complete dependency update plan.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from dependency.Change import Change - - -@dataclass(frozen=True, slots=True) -class Plan: - """The complete in-memory Dockerfile update.""" - - content: str - changes: tuple[Change, ...] - - @property - def changed(self) -> bool: - return any(change.changed for change in self.changes) diff --git a/.github/scripts/dependency/Source.py b/.github/scripts/dependency/Source.py deleted file mode 100644 index 5dce78e..0000000 --- a/.github/scripts/dependency/Source.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Authoritative dependency release source types.""" - -from __future__ import annotations - -from dependency.GitSource import GitSource -from dependency.PeclSource import PeclSource - - -Source = GitSource | PeclSource diff --git a/.github/scripts/dependency/UpdateError.py b/.github/scripts/dependency/UpdateError.py deleted file mode 100644 index c31304d..0000000 --- a/.github/scripts/dependency/UpdateError.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Dependency updater failures.""" - - -class UpdateError(RuntimeError): - """Raised when dependency discovery or Dockerfile validation fails.""" diff --git a/.github/scripts/dependency/Version.py b/.github/scripts/dependency/Version.py deleted file mode 100644 index 490bf63..0000000 --- a/.github/scripts/dependency/Version.py +++ /dev/null @@ -1,14 +0,0 @@ -"""A stable semantic version.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, order=True, slots=True) -class Version: - """A stable semantic release.""" - - major: int - minor: int - patch: int diff --git a/.github/scripts/dependency/__init__.py b/.github/scripts/dependency/__init__.py deleted file mode 100644 index 2fd9592..0000000 --- a/.github/scripts/dependency/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Dependency updater domain models.""" diff --git a/.github/scripts/orchestrator.py b/.github/scripts/orchestrator.py deleted file mode 100644 index c6b709d..0000000 --- a/.github/scripts/orchestrator.py +++ /dev/null @@ -1,874 +0,0 @@ -"""GitHub adapter for the weekly dependency release workflow.""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import time -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Sequence -from urllib.parse import quote - - -sys.path.insert(0, str(Path(__file__).parent)) - -from automation import ( # noqa: E402 - Deadline, - Merge, - MergeResult, - PullRequest, - PullRequestUnavailableError, - Recovery, - Release, - ReviewDecision, - Run, - Tag, - Version, - WorkflowState, - validate_release_target, - validate_tag_target, - workflow_state, -) - - -class Orchestrator: - """Run GitHub operations behind exact, testable domain invariants.""" - - def __init__(self) -> None: - self.repository = os.environ['GITHUB_REPOSITORY'] - self.version = os.environ['GITHUB_API_VERSION'] - self.header = f'X-GitHub-Api-Version: {self.version}' - - def execute(self, arguments: Sequence[str]) -> None: - """Dispatch one workflow operation.""" - if not arguments: - raise ValueError('An orchestration operation is required') - - operation, *values = arguments - if operation == 'recover' and not values: - self.recover() - return - if operation == 'merge' and len(values) == 3: - self.merge( - pull=int(values[0]), - head=values[1], - base=values[2], - ) - return - if operation == 'prepare' and len(values) == 4: - tag, target, pull, draft = values - self.prepare( - tag=tag or None, - target=target, - pull=int(pull), - draft=int(draft) if draft else None, - ) - return - if operation == 'wait' and len(values) == 2: - self.wait(tag=values[0], target=values[1]) - return - if operation == 'publish' and len(values) == 4: - self.publish( - tag=values[0], - target=values[1], - pull=int(values[2]), - draft=int(values[3]), - ) - return - raise ValueError(f'Invalid {operation!r} arguments') - - def _run( - self, - arguments: Sequence[str], - *, - check: bool = True, - ) -> subprocess.CompletedProcess[str]: - return subprocess.run( - arguments, - check=check, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - def _api( - self, - method: str, - endpoint: str, - fields: Sequence[tuple[str, str]] = (), - *, - check: bool = True, - ) -> subprocess.CompletedProcess[str]: - arguments = [ - 'gh', - 'api', - '-X', - method, - endpoint, - '-H', - self.header, - ] - for kind, value in fields: - arguments.extend((kind, value)) - return self._run(arguments, check=check) - - def _pages(self, endpoint: str) -> list[dict[str, Any]]: - result = self._run( - ( - 'gh', - 'api', - '--paginate', - '--slurp', - '-X', - 'GET', - endpoint, - '-H', - self.header, - '-f', - 'per_page=100', - ) - ) - pages = json.loads(result.stdout) - return [ - item - for page in pages - for item in page - if isinstance(item, dict) - ] - - def _write(self, values: dict[str, str]) -> None: - with open( - os.environ['GITHUB_OUTPUT'], - 'a', - encoding='utf-8', - ) as output: - for name, value in values.items(): - print(f'{name}={value}', file=output) - - def _tags(self) -> tuple[Tag, ...]: - prefix = 'refs/tags/' - return tuple( - Tag( - name=str(item.get('ref', '')).removeprefix(prefix), - target=str(item['object']['sha']), - ) - for item in self._pages( - f'repos/{self.repository}/git/matching-refs/tags/' - ) - if str(item.get('ref', '')).startswith(prefix) - and isinstance(item.get('object'), dict) - and item['object'].get('type') == 'commit' - ) - - def _listed_releases(self) -> tuple[Recovery, ...]: - return tuple( - Recovery( - identifier=int(item['id']), - tag=str(item['tag_name']), - target=str(item['target_commitish']), - pull=0, - draft=bool(item['draft']), - prerelease=bool(item['prerelease']), - body=str(item.get('body') or ''), - ) - for item in self._pages(f'repos/{self.repository}/releases') - ) - - def _read_graphql_release(self, tag: str) -> dict[str, Any] | None: - owner, separator, name = self.repository.partition('/') - if not separator or not owner or not name: - raise RuntimeError( - f'Invalid GitHub repository {self.repository!r}' - ) - query = ( - 'query($owner:String!,$name:String!,$tag:String!){' - 'repository(owner:$owner,name:$name){' - 'release(tagName:$tag){' - 'databaseId tagName isDraft isPrerelease description ' - 'tagCommit{oid}' - '}}}' - ) - result = self._api( - 'POST', - 'graphql', - ( - ('-f', f'owner={owner}'), - ('-f', f'name={name}'), - ('-f', f'tag={tag}'), - ('-f', f'query={query}'), - ), - ) - payload = json.loads(result.stdout) - if payload.get('errors'): - raise RuntimeError( - f'GitHub GraphQL release lookup failed for {tag}' - ) - data = payload.get('data') - if not isinstance(data, dict): - raise RuntimeError(f'Release lookup for {tag} is invalid') - repository = data.get('repository') - if not isinstance(repository, dict): - raise RuntimeError(f'Release lookup for {tag} is invalid') - release = repository.get('release') - if release is None: - return None - if not isinstance(release, dict): - raise RuntimeError(f'Release lookup for {tag} is invalid') - commit = release.get('tagCommit') - target = ( - str(commit.get('oid', '')) - if isinstance(commit, dict) - else '' - ) - return { - 'id': release['databaseId'], - 'tag_name': release['tagName'], - 'target_commitish': target, - 'draft': release['isDraft'], - 'prerelease': release['isPrerelease'], - 'body': release.get('description') or '', - } - - def _read_release_by_tag(self, tag: str) -> dict[str, Any] | None: - result = self._api( - 'GET', - ( - f'repos/{self.repository}/releases/tags/' - f'{quote(tag, safe="")}' - ), - check=False, - ) - if result.returncode == 0: - payload = json.loads(result.stdout) - if not isinstance(payload, dict): - raise RuntimeError(f'Release lookup for {tag} is invalid') - return payload - - try: - error = json.loads(result.stdout) - except json.JSONDecodeError as exception: - raise RuntimeError( - f'Release lookup failed for {tag}' - ) from exception - if not isinstance(error, dict) or str(error.get('status')) != '404': - sys.stderr.write(result.stderr) - raise RuntimeError(f'Release lookup failed for {tag}') - return self._read_graphql_release(tag) - - @staticmethod - def _release(payload: dict[str, Any]) -> Recovery: - return Recovery( - identifier=int(payload['id']), - tag=str(payload['tag_name']), - target=str(payload['target_commitish']), - pull=0, - draft=bool(payload['draft']), - prerelease=bool(payload['prerelease']), - body=str(payload.get('body') or ''), - ) - - def _releases(self, tags: Sequence[Tag]) -> tuple[Recovery, ...]: - listed = self._listed_releases() - stable: dict[str, tuple[Version, Tag]] = {} - for tag in tags: - version = Version.parse(tag.name) - if version is not None: - stable[tag.name] = (version, tag) - releases: dict[str, Recovery] = {} - threshold: Version | None = None - hints = sorted( - ( - release - for release in listed - if not release.draft - and not release.prerelease - and release.tag in stable - ), - key=lambda release: stable[release.tag][0], - reverse=True, - ) - for hint in hints: - payload = self._read_release_by_tag(hint.tag) - if payload is None: - continue - release = self._release(payload) - if release.tag != hint.tag: - raise RuntimeError( - f'Release lookup for {hint.tag} returned {release.tag}' - ) - releases[release.tag] = release - if not release.draft and not release.prerelease: - threshold = stable[release.tag][0] - break - - candidates = sorted( - ( - (version, tag) - for version, tag in stable.values() - if threshold is None or version > threshold - ), - reverse=True, - ) - for _, tag in candidates: - payload = self._read_release_by_tag(tag.name) - if payload is None: - continue - release = self._release(payload) - if release.tag != tag.name: - raise RuntimeError( - f'Release lookup for {tag.name} returned {release.tag}' - ) - releases[release.tag] = release - return tuple(releases.values()) - - def _read_parents(self, commit: str) -> tuple[str, ...]: - result = self._api( - 'GET', - f'repos/{self.repository}/commits/{commit}', - ) - payload = json.loads(result.stdout) - parents = payload.get('parents') - if not isinstance(parents, list): - raise RuntimeError(f'Commit {commit} has invalid parents') - if any( - not isinstance(parent, dict) or not parent.get('sha') - for parent in parents - ): - raise RuntimeError(f'Commit {commit} has invalid parents') - return tuple( - str(parent['sha']) - for parent in parents - ) - - def _merges(self) -> tuple[Merge, ...]: - merges: list[Merge] = [] - pulls = self._pages( - f'repos/{self.repository}/pulls' - '?state=closed&base=main&sort=updated&direction=desc' - ) - for pull in pulls: - head = pull.get('head') - body = str(pull.get('body') or '') - branch = ( - str(head.get('ref', '')) - if isinstance(head, dict) - else '' - ) - if ( - pull.get('merged_at') is None - or Merge.marker not in body - or not branch.startswith('automation/dependencies-') - ): - continue - number = int(pull['number']) - details = self._read_pull(number) - commit = details.get('mergeCommit') - target = ( - str(commit.get('oid')) - if isinstance(commit, dict) and commit.get('oid') - else None - ) - if ( - str(details.get('state', '')).lower() != 'merged' - or target is None - ): - continue - files = tuple( - str(item['filename']) - for item in self._pages( - f'repos/{self.repository}/pulls/{number}/files' - ) - ) - merges.append( - Merge( - number=number, - target=target, - head=str(details.get('headRefOid', '')), - parents=self._read_parents(target), - base=str(details.get('baseRefName', '')), - branch=branch, - body=body, - files=files, - state='merged', - ) - ) - return tuple(merges) - - def _read_tag(self, name: str) -> Tag | None: - result = self._api( - 'GET', - f'repos/{self.repository}/git/ref/tags/{name}', - check=False, - ) - if result.returncode != 0: - return None - payload = json.loads(result.stdout) - target = payload.get('object') - if not isinstance(target, dict) or target.get('type') != 'commit': - raise RuntimeError(f'Tag {name} is not lightweight') - return Tag( - name=str(payload.get('ref', '')).removeprefix('refs/tags/'), - target=str(target.get('sha')), - ) - - def _read_pull(self, number: int) -> dict[str, Any]: - result = self._run( - ( - 'gh', - 'pr', - 'view', - str(number), - '--repo', - self.repository, - '--json', - ( - 'baseRefName,baseRefOid,headRefOid,mergeCommit,' - 'mergeable,number,reviewDecision,state' - ), - '--jq', - '.', - ) - ) - payload = json.loads(result.stdout) - if not isinstance(payload, dict): - raise RuntimeError(f'Pull request #{number} is invalid') - return payload - - def merge(self, *, pull: int, head: str, base: str) -> None: - """Merge and prove the exact tested head and base.""" - payload = self._read_pull(pull) - if payload.get('baseRefName') != 'main': - raise PullRequestUnavailableError( - 'Pull request does not target main' - ) - try: - review = ReviewDecision( - str(payload.get('reviewDecision', '')).lower() - ) - except ValueError as exception: - raise PullRequestUnavailableError( - 'Pull request has no current approval' - ) from exception - PullRequest( - number=int(payload['number']), - head=str(payload['headRefOid']), - base=str(payload['baseRefOid']), - state=str(payload['state']).lower(), - review=review, - ).validate(head, base) - if payload.get('mergeable') != 'MERGEABLE': - raise PullRequestUnavailableError( - 'Pull request is not currently mergeable' - ) - - result = self._run( - ( - 'gh', - 'pr', - 'merge', - str(pull), - '--repo', - self.repository, - '--squash', - '--match-head-commit', - head, - ), - check=False, - ) - final = self._read_pull(pull) - commit = final.get('mergeCommit') - target = ( - str(commit.get('oid')) - if isinstance(commit, dict) and commit.get('oid') - else None - ) - parents = self._read_parents(target) if target is not None else () - merged = MergeResult( - head=str(final.get('headRefOid', '')), - state=str(final.get('state', '')).lower(), - commit=target, - parents=parents, - ).validate(head, base) - if result.returncode != 0: - print( - 'The merge command exited nonzero, but GitHub proved the ' - 'exact tested squash merge.', - flush=True, - ) - self._write({'head': merged}) - - def _read_release(self, identifier: int) -> dict[str, Any]: - result = self._api( - 'GET', - f'repos/{self.repository}/releases/{identifier}', - ) - payload = json.loads(result.stdout) - if not isinstance(payload, dict): - raise RuntimeError(f'Release {identifier} is invalid') - return payload - - def _draft_body(self, target: str, pull: int) -> str: - return ( - '\n' - f'\n' - f'\n\n' - 'Automated weekly dependency release.' - ) - - def _validate_draft( - self, - payload: dict[str, Any], - *, - tag: str, - target: str, - pull: int, - ) -> int: - draft = Recovery( - identifier=int(payload['id']), - tag=str(payload['tag_name']), - target=str(payload['target_commitish']), - pull=pull, - draft=bool(payload['draft']), - prerelease=bool(payload['prerelease']), - body=str(payload.get('body') or ''), - ) - merge = Merge( - number=pull, - target=target, - head='', - parents=(), - base='main', - branch='automation/dependencies-recovery', - body=Merge.marker, - files=('Dockerfile',), - state='merged', - ) - if draft.tag != tag or not draft.matches(merge): - raise RuntimeError(f'Draft release {draft.identifier} is unsafe') - return draft.identifier - - def recover(self) -> None: - """Recover only state proven to originate from this automation.""" - all_tags = self._tags() - releases = self._releases(all_tags) - candidate = Recovery.select( - all_tags, - releases, - self._merges(), - ) - if candidate is None: - self._write({'pending': 'false'}) - return - - self._write( - { - 'pending': 'true', - 'tag': candidate.tag or '', - 'head': candidate.target, - 'pull': str(candidate.pull), - 'draft': ( - str(candidate.draft) - if candidate.draft is not None - else '' - ), - } - ) - - def _create_tag(self, target: str) -> str: - available = tuple(tag.name for tag in self._tags()) - candidate = str(Version.next(available)) - while True: - result = self._api( - 'POST', - f'repos/{self.repository}/git/refs', - ( - ('-f', f'ref=refs/tags/{candidate}'), - ('-f', f'sha={target}'), - ), - check=False, - ) - if result.returncode == 0: - break - - existing = self._read_tag(candidate) - if existing is not None and existing.target == target: - break - if existing is None: - sys.stderr.write(result.stderr) - raise RuntimeError(f'Failed to create tag {candidate}') - available = tuple(tag.name for tag in self._tags()) - candidate = str( - Version.after_collision(available, candidate) - ) - - tag = self._read_tag(candidate) - if tag is None: - raise RuntimeError(f'Tag {candidate} is missing after creation') - validate_tag_target( - tag, - expected_name=candidate, - expected_target=target, - ) - return candidate - - def _create_draft(self, tag: str, target: str, pull: int) -> int: - existing = self._read_release_by_tag(tag) - if existing is not None: - if existing.get('draft') is not True: - raise RuntimeError(f'Release {tag} is already published') - return self._validate_draft( - existing, - tag=tag, - target=target, - pull=pull, - ) - - body = self._draft_body(target, pull) - result = self._api( - 'POST', - f'repos/{self.repository}/releases', - ( - ('-f', f'tag_name={tag}'), - ('-f', f'target_commitish={target}'), - ('-f', f'name={tag}'), - ('-f', f'body={body}'), - ('-F', 'draft=true'), - ('-F', 'prerelease=false'), - ('-F', 'generate_release_notes=true'), - ), - check=False, - ) - if result.returncode == 0: - payload = json.loads(result.stdout) - return self._validate_draft( - payload, - tag=tag, - target=target, - pull=pull, - ) - - existing = self._read_release_by_tag(tag) - if existing is None: - sys.stderr.write(result.stderr) - raise RuntimeError(f'Failed to create draft release {tag}') - if existing.get('draft') is not True: - raise RuntimeError(f'Release {tag} was published concurrently') - return self._validate_draft( - existing, - tag=tag, - target=target, - pull=pull, - ) - - def prepare( - self, - *, - tag: str | None, - target: str, - pull: int, - draft: int | None, - ) -> None: - """Create or recover an exact lightweight tag and draft release.""" - name = tag if tag is not None else self._create_tag(target) - existing = self._read_tag(name) - if existing is None: - raise RuntimeError(f'Tag {name} does not exist') - validate_tag_target( - existing, - expected_name=name, - expected_target=target, - ) - - identifier = ( - self._validate_draft( - self._read_release(draft), - tag=name, - target=target, - pull=pull, - ) - if draft is not None - else self._create_draft(name, target, pull) - ) - current = self._read_tag(name) - if current is None: - raise RuntimeError(f'Tag {name} disappeared during preparation') - validate_tag_target( - current, - expected_name=name, - expected_target=target, - ) - self._write( - { - 'tag': name, - 'head': target, - 'pull': str(pull), - 'draft': str(identifier), - } - ) - - def _runs(self, tag: str, target: str) -> tuple[Run, ...]: - result = self._api( - 'GET', - ( - f'repos/{self.repository}/actions/workflows/' - 'build-and-push.yml/runs' - ), - ( - ('-f', f'branch={tag}'), - ('-f', f'head_sha={target}'), - ('-f', 'event=push'), - ('-f', 'per_page=100'), - ), - ) - payload = json.loads(result.stdout) - return tuple( - Run( - identifier=int(item['id']), - workflow=str(item['name']), - event=str(item['event']), - head=str(item['head_sha']), - branch=str(item['head_branch']), - created=datetime.fromisoformat( - str(item['created_at']).replace('Z', '+00:00') - ), - attempt=int(item['run_attempt']), - status=str(item['status']), - conclusion=( - str(item['conclusion']) - if item['conclusion'] is not None - else None - ), - ) - for item in payload['workflow_runs'] - ) - - def wait(self, *, tag: str, target: str) -> None: - """Require the exact tag-push Build and Push run to succeed.""" - boundary = datetime(1970, 1, 1, tzinfo=timezone.utc) - deadline = Deadline.after( - datetime.now(timezone.utc), - timedelta(minutes=120), - ) - previous = None - while True: - current = self._read_tag(tag) - if current is None: - raise RuntimeError(f'Tag {tag} disappeared during its build') - validate_tag_target( - current, - expected_name=tag, - expected_target=target, - ) - now = datetime.now(timezone.utc) - state = workflow_state( - self._runs(tag, target), - workflow='Build and Push', - event='push', - head=target, - branch=tag, - created=boundary, - deadline=deadline, - now=now, - ) - if state is not previous: - print(f'Build and Push tag run: {state.value}', flush=True) - previous = state - if state is WorkflowState.SUCCEEDED: - return - if state in { - WorkflowState.CANCELLED, - WorkflowState.FAILED, - WorkflowState.TIMED_OUT, - }: - raise RuntimeError( - f'Tag Build and Push did not succeed: {state.value}' - ) - remaining = deadline.remaining( - datetime.now(timezone.utc) - ).total_seconds() - time.sleep(min(20, max(remaining, 0))) - - def publish( - self, - *, - tag: str, - target: str, - pull: int, - draft: int, - ) -> None: - """Publish only an exact draft and re-draft any wrong target.""" - current = self._read_tag(tag) - if current is None: - raise RuntimeError(f'Tag {tag} disappeared before publication') - validate_tag_target( - current, - expected_name=tag, - expected_target=target, - ) - self._validate_draft( - self._read_release(draft), - tag=tag, - target=target, - pull=pull, - ) - - result = self._api( - 'PATCH', - f'repos/{self.repository}/releases/{draft}', - ( - ('-F', 'draft=false'), - ('-F', 'prerelease=false'), - ('-f', 'make_latest=true'), - ), - check=False, - ) - payload = ( - json.loads(result.stdout) - if result.returncode == 0 and result.stdout - else self._read_release(draft) - ) - final = self._read_tag(tag) - valid = ( - final is not None - and final.name == tag - and final.target == target - and payload.get('tag_name') == tag - and payload.get('draft') is False - and payload.get('prerelease') is False - ) - if not valid: - rollback = self._api( - 'PATCH', - f'repos/{self.repository}/releases/{draft}', - (('-F', 'draft=true'),), - check=False, - ) - rolled_back = self._read_release(draft) - if rolled_back.get('draft') is not True: - sys.stderr.write(rollback.stderr) - raise RuntimeError( - f'Release {tag} has an unsafe public target and ' - 'could not be returned to draft' - ) - raise RuntimeError( - f'Release {tag} target changed during publication; ' - 'the release was returned to draft' - ) - - validate_tag_target( - final, - expected_name=tag, - expected_target=target, - ) - validate_release_target( - Release(tag=str(payload['tag_name']), target=final.target), - expected_tag=tag, - expected_target=target, - ) - - -if __name__ == '__main__': - Orchestrator().execute(sys.argv[1:]) diff --git a/.github/scripts/src/Automation/Application.php b/.github/scripts/src/Automation/Application.php index d940229..64cf8b5 100644 --- a/.github/scripts/src/Automation/Application.php +++ b/.github/scripts/src/Automation/Application.php @@ -4,6 +4,7 @@ namespace DockerBase\Automation; +use DockerBase\Automation\Pull\Payload; use InvalidArgumentException; final readonly class Application @@ -18,7 +19,7 @@ public function __construct( * * @return array */ - public function execute(array $arguments): array + public function execute(array $arguments, string $input = ''): array { if ($arguments === []) { throw new InvalidArgumentException( @@ -33,6 +34,17 @@ public function execute(array $arguments): array 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]), @@ -105,6 +117,19 @@ private function wait(string $tag, string $target): array return []; } + /** + * @return array + */ + private function checks( + string $branch, + string $head, + string $created, + ): array { + $this->orchestrator->checks($branch, $head, $created); + + return []; + } + /** * @return array */ diff --git a/.github/scripts/src/Automation/Orchestrator.php b/.github/scripts/src/Automation/Orchestrator.php index 173c980..c4e523c 100644 --- a/.github/scripts/src/Automation/Orchestrator.php +++ b/.github/scripts/src/Automation/Orchestrator.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; +use InvalidArgumentException; use RuntimeException; final readonly class Orchestrator @@ -19,6 +20,16 @@ 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, @@ -143,6 +154,88 @@ public function wait(string $tag, string $target): void } } + 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, diff --git a/.github/scripts/src/Automation/Pull/Payload.php b/.github/scripts/src/Automation/Pull/Payload.php new file mode 100644 index 0000000..c718207 --- /dev/null +++ b/.github/scripts/src/Automation/Pull/Payload.php @@ -0,0 +1,64 @@ + (string) $number, + 'base' => $base, + ]; + } +} diff --git a/.github/scripts/src/Dependency/Console.php b/.github/scripts/src/Dependency/Console.php new file mode 100644 index 0000000..f577076 --- /dev/null +++ b/.github/scripts/src/Dependency/Console.php @@ -0,0 +1,63 @@ + $arguments + */ + public function execute(array $arguments): string + { + $dockerfile = $this->dockerfile; + $dryRun = false; + + for ($index = 0; $index < count($arguments); ++$index) { + $argument = $arguments[$index]; + if ($argument === '--dry-run') { + $dryRun = true; + + continue; + } + if ($argument === '--dockerfile') { + $dockerfile = $arguments[++$index] ?? ''; + if ($dockerfile === '') { + throw new InvalidArgumentException( + '--dockerfile requires a path', + ); + } + + continue; + } + if (str_starts_with($argument, '--dockerfile=')) { + $dockerfile = substr($argument, strlen('--dockerfile=')); + if ($dockerfile === '') { + throw new InvalidArgumentException( + '--dockerfile requires a path', + ); + } + + continue; + } + + throw new InvalidArgumentException( + "Unknown dependency updater argument '{$argument}'", + ); + } + + return $this->reporter->render( + $this->updater->update($dockerfile, $dryRun), + ); + } +} diff --git a/.github/scripts/test_automation.py b/.github/scripts/test_automation.py deleted file mode 100644 index 7e7568c..0000000 --- a/.github/scripts/test_automation.py +++ /dev/null @@ -1,656 +0,0 @@ -import sys -import unittest -from datetime import datetime, timedelta, timezone -from pathlib import Path - - -sys.path.insert(0, str(Path(__file__).parent)) - -from automation import ( # noqa: E402 - ApprovalMissingError, - Candidate, - Deadline, - HeadChangedError, - Merge, - MergeResult, - PullRequest, - Recovery, - RecoveryError, - Release, - ReviewDecision, - Run, - Tag, - TargetMismatchError, - Version, - VersionInvalidError, - VersionMissingError, - WorkflowState, - latest_version, - newer_unreleased_tag, - next_patch, - patch_after_collision, - release_candidate, - select_run, - stable_versions, - validate_pull_request, - validate_release_target, - validate_tag_target, - workflow_state, -) - - -UTC = timezone.utc -START = datetime(2026, 7, 24, 8, 0, tzinfo=UTC) - - -def run( - *, - identifier: int = 1, - workflow: str = 'Build and Push', - event: str = 'push', - head: str = 'approved-head', - branch: str = 'main', - created: datetime = START, - attempt: int = 1, - status: str = 'completed', - conclusion: str | None = 'success', -) -> Run: - return Run( - identifier=identifier, - workflow=workflow, - event=event, - head=head, - branch=branch, - created=created, - attempt=attempt, - status=status, - conclusion=conclusion, - ) - - -class VersionTest(unittest.TestCase): - def test_orders_stable_versions_semantically(self) -> None: - self.assertEqual( - ( - Version(1, 2, 99), - Version(1, 10, 0), - Version(2, 0, 0), - ), - stable_versions( - ['1.10.0', '2.0.0', '1.2.99', '1.10.0'] - ), - ) - - def test_ignores_nonstable_and_prefixed_tags(self) -> None: - self.assertEqual( - (Version(0, 0, 0), Version(12, 34, 56)), - stable_versions( - [ - '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', - '', - ] - ), - ) - - def test_next_patch_uses_semantic_maximum_of_all_remote_tags(self) -> None: - self.assertEqual( - Version(2, 0, 1), - next_patch( - ['1.999.999', '2.0.0', 'v9.0.0', '9.0.0-rc.1'] - ), - ) - - def test_latest_version_requires_a_stable_remote_tag(self) -> None: - with self.assertRaises(VersionMissingError): - latest_version(['v1.0.0', '1.0.0-rc.1']) - - def test_resumes_newest_tag_newer_than_latest_release(self) -> None: - tags = ['1.4.1', '1.4.2', '1.4.3', '1.4.4'] - releases = ['1.4.1', '1.4.3'] - - self.assertEqual( - Version(1, 4, 4), - newer_unreleased_tag(tags, releases), - ) - self.assertEqual( - Version(1, 4, 4), - release_candidate(tags, releases), - ) - - def test_ignores_unreleased_holes_older_than_latest_release(self) -> None: - self.assertIsNone( - newer_unreleased_tag( - ['1.4.1', '1.4.2', '1.4.3'], - ['1.4.1', '1.4.3'], - ) - ) - - def test_computes_new_patch_when_every_newer_tag_is_released(self) -> None: - self.assertEqual( - Version(1, 4, 5), - release_candidate( - ['1.4.3', '1.4.4'], - ['1.4.3', '1.4.4'], - ), - ) - - def test_recomputes_after_collision_from_refreshed_remote_tags(self) -> None: - self.assertEqual( - Version(1, 4, 8), - patch_after_collision( - ['1.4.4', '1.4.5', '1.4.7'], - '1.4.5', - ), - ) - - def test_rejects_malformed_collision_tag(self) -> None: - with self.assertRaises(VersionInvalidError): - patch_after_collision(['1.4.4'], 'v1.4.5') - - -class DeadlineTest(unittest.TestCase): - def test_uses_injected_time_and_clamps_remaining_time(self) -> None: - deadline = Deadline.after(START, timedelta(minutes=10)) - - self.assertFalse(deadline.expired(START + timedelta(minutes=9))) - self.assertTrue(deadline.expired(START + timedelta(minutes=10))) - self.assertEqual( - timedelta(minutes=1), - deadline.remaining(START + timedelta(minutes=9)), - ) - self.assertEqual( - timedelta(), - deadline.remaining(START + timedelta(minutes=11)), - ) - - def test_rejects_naive_times_and_nonpositive_timeouts(self) -> None: - with self.assertRaises(ValueError): - Deadline(datetime(2026, 7, 24, 8, 0)) - with self.assertRaises(ValueError): - Deadline.after(START, timedelta()) - - -class WorkflowTest(unittest.TestCase): - def setUp(self) -> None: - self.deadline = Deadline.after(START, timedelta(minutes=30)) - - def state( - self, - runs: list[Run], - *, - now: datetime = START, - ) -> WorkflowState: - return workflow_state( - runs, - workflow='Build and Push', - event='push', - head='approved-head', - branch='main', - created=START, - deadline=self.deadline, - now=now, - ) - - def test_selects_only_exact_runs_at_or_after_boundary(self) -> None: - expected = run(identifier=8, created=START + timedelta(seconds=1)) - runs = [ - run(identifier=1, workflow='Dive Test'), - run(identifier=2, event='pull_request'), - run(identifier=3, head='other-head'), - run(identifier=4, branch='feature'), - run(identifier=5, created=START - timedelta(microseconds=1)), - expected, - ] - - self.assertIs( - expected, - select_run( - runs, - workflow='Build and Push', - event='push', - head='approved-head', - branch='main', - created=START, - ), - ) - - def test_selects_newest_run_then_newest_rerun_attempt(self) -> None: - earlier = START + timedelta(seconds=1) - later = START + timedelta(seconds=2) - expected = run(identifier=20, created=later, attempt=2) - runs = [ - run(identifier=10, created=earlier, attempt=3), - run( - identifier=20, - created=later, - attempt=1, - conclusion='failure', - ), - expected, - ] - - self.assertIs( - expected, - select_run( - runs, - workflow='Build and Push', - event='push', - head='approved-head', - branch='main', - created=START, - ), - ) - - def test_reports_missing_run_before_deadline(self) -> None: - self.assertIs(WorkflowState.MISSING, self.state([])) - - def test_reports_successful_run(self) -> None: - self.assertIs(WorkflowState.SUCCEEDED, self.state([run()])) - - def test_does_not_accept_branch_run_for_tag_release(self) -> None: - state = workflow_state( - [run(branch='main')], - workflow='Build and Push', - event='push', - head='approved-head', - branch='1.4.5', - created=START, - deadline=self.deadline, - now=START, - ) - - self.assertIs(WorkflowState.MISSING, state) - - def test_reports_failed_run(self) -> None: - self.assertIs( - WorkflowState.FAILED, - self.state([run(conclusion='failure')]), - ) - - def test_reports_cancelled_run(self) -> None: - self.assertIs( - WorkflowState.CANCELLED, - self.state([run(conclusion='cancelled')]), - ) - - def test_reports_run_timeout_conclusion(self) -> None: - self.assertIs( - WorkflowState.TIMED_OUT, - self.state([run(conclusion='timed_out')]), - ) - - def test_reports_pending_run_before_deadline(self) -> None: - self.assertIs( - WorkflowState.PENDING, - self.state( - [run(status='in_progress', conclusion=None)], - now=START + timedelta(minutes=29), - ), - ) - - def test_times_out_pending_run_at_deadline(self) -> None: - self.assertIs( - WorkflowState.TIMED_OUT, - self.state( - [run(status='queued', conclusion=None)], - now=START + timedelta(minutes=30), - ), - ) - - def test_times_out_missing_run_at_deadline(self) -> None: - self.assertIs( - WorkflowState.TIMED_OUT, - self.state([], now=START + timedelta(minutes=30)), - ) - - def test_preserves_terminal_failure_after_deadline(self) -> None: - self.assertIs( - WorkflowState.FAILED, - self.state( - [run(conclusion='failure')], - now=START + timedelta(minutes=31), - ), - ) - - -class PullRequestTest(unittest.TestCase): - def test_accepts_current_approved_head(self) -> None: - validate_pull_request( - PullRequest( - number=75, - head='approved-head', - base='tested-base', - state='open', - review=ReviewDecision.APPROVED, - ), - 'approved-head', - 'tested-base', - ) - - def test_rejects_changed_head_even_if_currently_approved(self) -> None: - with self.assertRaises(HeadChangedError): - validate_pull_request( - PullRequest( - number=75, - head='changed-head', - base='tested-base', - state='open', - review=ReviewDecision.APPROVED, - ), - 'approved-head', - 'tested-base', - ) - - def test_rejects_changed_base_after_ci_succeeds(self) -> None: - with self.assertRaises(HeadChangedError): - validate_pull_request( - PullRequest( - number=75, - head='approved-head', - base='changed-base', - state='open', - review=ReviewDecision.APPROVED, - ), - 'approved-head', - 'tested-base', - ) - - def test_rejects_missing_current_approval(self) -> None: - with self.assertRaises(ApprovalMissingError): - validate_pull_request( - PullRequest( - number=75, - head='approved-head', - base='tested-base', - state='open', - review=ReviewDecision.REVIEW_REQUIRED, - ), - 'approved-head', - 'tested-base', - ) - - -class MergeResultTest(unittest.TestCase): - def test_accepts_merge_for_exact_tested_head(self) -> None: - self.assertEqual( - 'b' * 40, - MergeResult( - head='a' * 40, - state='merged', - commit='b' * 40, - parents=('c' * 40,), - ).validate('a' * 40, 'c' * 40), - ) - - def test_rejects_merged_state_for_a_different_final_head(self) -> None: - with self.assertRaises(HeadChangedError): - MergeResult( - head='c' * 40, - state='merged', - commit='b' * 40, - parents=('d' * 40,), - ).validate('a' * 40, 'd' * 40) - - def test_rejects_merge_commit_for_a_different_tested_base(self) -> None: - with self.assertRaises(HeadChangedError): - MergeResult( - head='a' * 40, - state='merged', - commit='b' * 40, - parents=('e' * 40,), - ).validate('a' * 40, 'd' * 40) - - def test_rejects_a_non_squash_merge_commit(self) -> None: - with self.assertRaises(HeadChangedError): - MergeResult( - head='a' * 40, - state='merged', - commit='b' * 40, - parents=('c' * 40, 'd' * 40), - ).validate('a' * 40, 'c' * 40) - - -class TargetTest(unittest.TestCase): - def test_accepts_exact_tag_and_release_targets(self) -> None: - validate_tag_target( - Tag(name='1.4.5', target='merged-head'), - expected_name='1.4.5', - expected_target='merged-head', - ) - validate_release_target( - Release(tag='1.4.5', target='merged-head'), - expected_tag='1.4.5', - expected_target='merged-head', - ) - - def test_rejects_tag_target_mismatch(self) -> None: - with self.assertRaises(TargetMismatchError): - validate_tag_target( - Tag(name='1.4.5', target='wrong-head'), - expected_name='1.4.5', - expected_target='merged-head', - ) - - def test_rejects_release_target_mismatch(self) -> None: - with self.assertRaises(TargetMismatchError): - validate_release_target( - Release(tag='1.4.5', target='wrong-head'), - expected_tag='1.4.5', - expected_target='merged-head', - ) - - -class RecoveryTest(unittest.TestCase): - def merge( - self, - *, - number: int = 75, - target: str = 'a' * 40, - head: str = 'b' * 40, - parent: str = 'c' * 40, - tested_base: str | None = None, - body: str | None = None, - files: tuple[str, ...] = ('Dockerfile',), - ) -> Merge: - proof = parent if tested_base is None else tested_base - return Merge( - number=number, - target=target, - head=head, - parents=(parent,), - base='main', - branch='automation/dependencies-100-1', - body=( - ( - '\n' - f'\n' - f'' - ) - if body is None - else body - ), - files=files, - state='merged', - ) - - def release( - self, - *, - identifier: int = 10, - tag: str = '1.4.5', - target: str = 'a' * 40, - pull: int = 75, - draft: bool = True, - ) -> Recovery: - return Recovery( - identifier=identifier, - tag=tag, - target=target, - pull=pull, - draft=draft, - prerelease=False, - body=( - '\n' - f'\n' - f'' - ), - ) - - def test_resumes_draft_after_publish_failure(self) -> None: - target = 'a' * 40 - - self.assertEqual( - Candidate( - tag='1.4.5', - target=target, - pull=75, - draft=10, - ), - Recovery.select( - [Tag(name='1.4.5', target=target)], - [ - self.release(), - self.release( - identifier=9, - tag='1.4.4', - draft=False, - ), - ], - [self.merge()], - ) - ) - - def test_resumes_tag_when_draft_creation_failed_and_next_run_has_no_diff( - self, - ) -> None: - target = 'a' * 40 - - self.assertEqual( - Candidate( - tag='1.4.5', - target=target, - pull=75, - draft=None, - ), - Recovery.select( - [Tag(name='1.4.5', target=target)], - [ - self.release( - identifier=9, - tag='1.4.4', - draft=False, - ) - ], - [self.merge()], - ) - ) - - def test_resumes_proven_merge_when_cancelled_before_tag_creation( - self, - ) -> None: - target = 'd' * 40 - - self.assertEqual( - Candidate( - tag=None, - target=target, - pull=76, - draft=None, - ), - Recovery.select( - [Tag(name='1.4.4', target='a' * 40)], - [self.release(tag='1.4.4', draft=False)], - [self.merge(number=76, target=target)], - ), - ) - - def test_does_not_resume_merge_of_an_untested_base(self) -> None: - self.assertIsNone( - Recovery.select( - [Tag(name='1.4.4', target='a' * 40)], - [self.release(tag='1.4.4', draft=False)], - [ - self.merge( - target='d' * 40, - parent='e' * 40, - tested_base='c' * 40, - ) - ], - ) - ) - - def test_fails_closed_for_ambiguous_proven_untagged_merges(self) -> None: - with self.assertRaises(RecoveryError): - Recovery.select( - [Tag(name='1.4.4', target='a' * 40)], - [self.release(tag='1.4.4', draft=False)], - [ - self.merge(number=76, target='d' * 40), - self.merge(number=77, target='e' * 40), - ], - ) - - def test_ignores_unrelated_orphan_tag(self) -> None: - self.assertIsNone( - Recovery.select( - [Tag(name='9.9.9', target='b' * 40)], - [self.release(tag='1.4.4', draft=False)], - [self.merge(body='No automation marker')], - ) - ) - - def test_ignores_tag_for_unmarked_or_multi_file_pull_request(self) -> None: - target = 'a' * 40 - - self.assertIsNone( - Recovery.select( - [Tag(name='1.4.5', target=target)], - [self.release(tag='1.4.4', draft=False)], - [ - self.merge(body='No automation marker'), - self.merge(files=('Dockerfile', 'README.md')), - ], - ) - ) - - def test_fails_closed_for_multiple_recoverable_releases(self) -> None: - first = 'a' * 40 - second = 'b' * 40 - - with self.assertRaises(RecoveryError): - Recovery.select( - [ - Tag(name='1.4.5', target=first), - Tag(name='1.4.6', target=second), - ], - [self.release(tag='1.4.4', draft=False)], - [ - self.merge(target=first), - self.merge(number=76, target=second), - ], - ) - - def test_does_not_resume_wrong_target_draft(self) -> None: - target = 'a' * 40 - - with self.assertRaises(RecoveryError): - Recovery.select( - [Tag(name='1.4.5', target=target)], - [ - self.release(tag='1.4.4', draft=False), - self.release(target='b' * 40), - ], - [self.merge()], - ) - - -if __name__ == '__main__': - unittest.main() diff --git a/.github/scripts/test_dependencies.py b/.github/scripts/test_dependencies.py deleted file mode 100644 index 5ac9893..0000000 --- a/.github/scripts/test_dependencies.py +++ /dev/null @@ -1,758 +0,0 @@ -#!/usr/bin/env python3 -"""Offline tests for the Dockerfile dependency updater.""" - -from __future__ import annotations - -import ast -import inspect -import re -import sys -import tempfile -import unittest -from dataclasses import FrozenInstanceError -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import dependencies - - -CURRENT: dict[str, str] = { - 'brotli': '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', -} -DECLARATIONS: tuple[tuple[str, str], ...] = ( - ('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'), -) -EXPECTED_DOCKERFILE_DECLARATIONS = frozenset( - ( - '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', - ) -) -OLD_DIGEST = 'sha256:' + ('1' * 64) -NEW_DIGEST = 'sha256:' + ('2' * 64) - - -def dockerfile() -> str: - """Build a representative Dockerfile containing every required pin.""" - - lines = [ - f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{OLD_DIGEST}"', - '', - 'FROM $BASE_IMAGE AS compile', - '', - 'ENV \\', - ] - for index, (name, variable) in enumerate(DECLARATIONS): - suffix = ' \\' if index < len(DECLARATIONS) - 1 else '' - lines.append( - f' {variable}="{CURRENT[name]}"{suffix}' - ) - lines.extend( - ( - '', - '# References should never be rewritten:', - 'RUN echo "$PHP_REDIS_VERSION"', - '', - 'FROM compile AS xdebug-build', - '', - f'ENV PHP_XDEBUG_VERSION="{CURRENT["xdebug"]}"', - '', - ) - ) - return '\n'.join(lines) - - -def git_tags(*spellings: str) -> str: - """Build offline git ls-remote output.""" - - return ''.join( - f'{"a" * 40}\trefs/tags/{spelling}\n' - for spelling in spellings - ) - - -def pecl_releases(*releases: tuple[str, str]) -> bytes: - """Build an offline namespaced PECL allreleases response.""" - - entries = ''.join( - f'{version}{state}' - for version, state in releases - ) - return ( - '' - '' - f'

protobuf

{entries}
' - ).encode() - - -class Discovery: - """Injectable, offline replacement for every command and network read.""" - - def __init__( - self, - *, - digest: str = OLD_DIGEST, - releases: dict[str, tuple[str, ...]] | None = None, - pecl: tuple[tuple[str, str], ...] | None = None, - ) -> None: - self.digest = digest - self.releases = releases or {} - self.pecl = pecl or ((CURRENT['protobuf'], 'stable'),) - self.commands: list[tuple[str, ...]] = [] - self.urls: list[str] = [] - - def run(self, command: tuple[str, ...]) -> str: - """Return deterministic output for docker buildx or git.""" - - self.commands.append(command) - if command == ( - 'docker', - 'buildx', - 'imagetools', - 'inspect', - dependencies.BASE_NAME, - ): - return ( - f'Name: docker.io/library/{dependencies.BASE_NAME}\n' - f'Digest: {self.digest}\n' - ) - if command[:4] == ('git', 'ls-remote', '--tags', '--refs'): - url = command[4] - dependency = next( - item - for item in dependencies.DEPENDENCIES - if isinstance(item.source, dependencies.GitSource) - and item.source.url == url - ) - spellings = self.releases.get( - dependency.name, - (CURRENT[dependency.name],), - ) - return git_tags(*spellings) - raise AssertionError(f'Unexpected command: {command}') - - def fetch(self, url: str) -> bytes: - """Return a deterministic PECL response.""" - - self.urls.append(url) - if url != dependencies.PECL_RELEASES: - raise AssertionError(f'Unexpected URL: {url}') - return pecl_releases(*self.pecl) - - -class SourceTests(unittest.TestCase): - """Verify the immutable source contract against Dockerfile clone URLs.""" - - def test_defines_all_thirteen_extensions(self) -> None: - self.assertEqual(13, len(dependencies.DEPENDENCIES)) - self.assertEqual( - set(CURRENT), - { - dependency.name - for dependency in dependencies.DEPENDENCIES - }, - ) - - def test_uses_exact_dockerfile_sources(self) -> None: - 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 = { - dependency.name: dependency.source.url - for dependency in dependencies.DEPENDENCIES - if isinstance(dependency.source, dependencies.GitSource) - } - self.assertEqual(expected, actual) - protobuf = next( - dependency - for dependency in dependencies.DEPENDENCIES - if dependency.name == 'protobuf' - ) - self.assertIsInstance(protobuf.source, dependencies.PeclSource) - self.assertEqual(dependencies.PECL_RELEASES, protobuf.source.url) - - def test_source_definitions_are_immutable(self) -> None: - source = dependencies.GitSource('https://example.test/repository') - with self.assertRaises(FrozenInstanceError): - source.url = 'https://example.test/changed' - - def test_domain_classes_are_in_matching_files(self) -> None: - classes = ( - dependencies.Change, - dependencies.CommandRunner, - dependencies.Dependency, - dependencies.Fetcher, - dependencies.GitSource, - dependencies.PeclSource, - dependencies.Pin, - dependencies.Plan, - dependencies.UpdateError, - dependencies.Version, - ) - for domain_class in classes: - with self.subTest(domain_class=domain_class.__name__): - path = inspect.getsourcefile(domain_class) - self.assertIsNotNone(path) - self.assertEqual( - f'{domain_class.__name__}.py', - Path(path).name, - ) - - path = Path(dependencies.__file__) - module = ast.parse(path.read_text(encoding='utf-8')) - self.assertEqual( - [], - [ - node.name - for node in module.body - if isinstance(node, ast.ClassDef) - ], - ) - - -class VersionTests(unittest.TestCase): - """Verify stable semantic same-major selection.""" - - def test_parses_only_exact_stable_versions(self) -> None: - self.assertEqual( - dependencies.Version(1, 2, 3), - dependencies.parse_version('1.2.3'), - ) - self.assertEqual( - dependencies.Version(1, 2, 3), - dependencies.parse_version('v1.2.3'), - ) - for spelling in ( - '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', - ): - with self.subTest(spelling=spelling): - self.assertIsNone(dependencies.parse_version(spelling)) - - def test_selects_semantic_maximum_not_lexical_maximum(self) -> None: - self.assertEqual( - '1.10.0', - dependencies.select_version( - '1.2.0', - ('1.2.9', '1.10.0', '1.9.12'), - ), - ) - - def test_selects_minor_and_patch_updates(self) -> None: - self.assertEqual( - '1.3.0', - dependencies.select_version( - '1.2.3', - ('1.2.4', '1.3.0'), - ), - ) - self.assertEqual( - '1.2.4', - dependencies.select_version('1.2.3', ('1.2.4',)), - ) - - def test_ignores_higher_major(self) -> None: - self.assertEqual( - '1.2.3', - dependencies.select_version( - '1.2.3', - ('2.0.0', 'v3.4.5'), - ), - ) - - def test_ignores_prereleases(self) -> None: - self.assertEqual( - '1.2.4', - dependencies.select_version( - '1.2.3', - ('1.2.4RC1', 'v1.3.0-beta.1', '1.2.4'), - ), - ) - - def test_never_downgrades(self) -> None: - self.assertEqual( - '1.5.0', - dependencies.select_version( - '1.5.0', - ('1.4.9', '1.5.0', '2.0.0'), - ), - ) - - def test_preserves_selected_upstream_prefix(self) -> None: - self.assertEqual( - 'v1.3.0', - dependencies.select_version('1.2.3', ('v1.3.0',)), - ) - self.assertEqual( - '1.3.0', - dependencies.select_version('v1.2.3', ('1.3.0',)), - ) - - def test_prefers_current_prefix_for_equivalent_tags(self) -> None: - releases = ('1.3.0', 'v1.3.0') - self.assertEqual( - 'v1.3.0', - dependencies.select_version('v1.2.3', releases), - ) - self.assertEqual( - '1.3.0', - dependencies.select_version('1.2.3', releases), - ) - - def test_rejects_invalid_current_version(self) -> None: - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Invalid current version', - ): - dependencies.select_version('1.2.3RC1', ('1.2.4',)) - - -class ReleaseParsingTests(unittest.TestCase): - """Verify exact git tags and PECL stable-state handling.""" - - def test_parses_only_exact_git_version_tags(self) -> None: - output = ( - git_tags('1.2.3', 'v1.3.0', '1.4.0RC1', 'release-1.5.0') - + f'{"b" * 40}\trefs/heads/1.6.0\n' - + 'malformed\n' - ) - self.assertEqual( - ('1.2.3', 'v1.3.0'), - dependencies.parse_git_tags(output), - ) - - def test_filters_pecl_by_stable_state_and_exact_version(self) -> None: - document = pecl_releases( - ('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.assertEqual( - ('5.35.0', '5.34.2', 'v5.35.1'), - dependencies.parse_pecl_releases(document), - ) - - def test_rejects_invalid_pecl_xml(self) -> None: - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Invalid PECL release XML', - ): - dependencies.parse_pecl_releases(b'') - - def test_rejects_source_with_no_stable_releases(self) -> None: - protobuf = next( - dependency - for dependency in dependencies.DEPENDENCIES - if dependency.name == 'protobuf' - ) - discovery = Discovery(pecl=(('5.35.0RC1', 'beta'),)) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'No exact stable releases found for protobuf', - ): - dependencies.discover_releases( - protobuf, - discovery.run, - discovery.fetch, - ) - - -class DockerfileTests(unittest.TestCase): - """Verify declaration validation, digest resolution, and updates.""" - - def test_reads_every_expected_declaration_once(self) -> None: - pins = dependencies.read_pins(dockerfile()) - self.assertEqual(14, len(pins)) - self.assertEqual(dependencies.BASE_NAME, pins[0].name) - self.assertEqual(OLD_DIGEST, pins[0].current) - self.assertEqual(set(CURRENT), {pin.name for pin in pins[1:]}) - - def test_real_dockerfile_declarations_match_independent_contract( - self, - ) -> None: - path = Path(__file__).resolve().parents[2] / 'Dockerfile' - content = path.read_text(encoding='utf-8') - declaration = re.compile( - r'(?m)^[ \t]*(?:(?:ARG|ENV)[ \t]+)?' - r'((?:BASE_IMAGE|PHP_[A-Z0-9_]+_VERSION))[ \t]*=' - ) - - self.assertEqual( - EXPECTED_DOCKERFILE_DECLARATIONS, - { - match.group(1) - for match in declaration.finditer(content) - }, - ) - pins = dependencies.read_pins(content) - self.assertEqual( - {dependencies.BASE_NAME, *CURRENT}, - {pin.name for pin in pins}, - ) - self.assertRegex( - pins[0].current, - r'\Asha256:[0-9a-f]{64}\Z', - ) - - def test_rejects_unknown_extension_declaration(self) -> None: - multiline = dockerfile().replace( - 'ENV \\\n', - 'ENV \\\n PHP_UNKNOWN_VERSION="1.2.3" \\\n', - 1, - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Unknown PHP extension version declaration: ' - 'PHP_UNKNOWN_VERSION', - ): - dependencies.read_pins(multiline) - - same_line = ( - dockerfile() - + '\nENV PHP_second_VERSION="1.2.3" ' - + 'PHP_THIRD_VERSION="2.3.4"\n' - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Unknown PHP extension version declarations: ' - 'PHP_THIRD_VERSION, PHP_second_VERSION', - ): - dependencies.read_pins(same_line) - - argument = dockerfile() + '\nARG PHP_ARGUMENT_VERSION\n' - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Unknown PHP extension version declaration: ' - 'PHP_ARGUMENT_VERSION', - ): - dependencies.read_pins(argument) - - def test_rejects_missing_declaration(self) -> None: - content = dockerfile().replace( - f' PHP_REDIS_VERSION="{CURRENT["redis"]}" \\\n', - '', - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Expected exactly one PHP_REDIS_VERSION declaration, found 0', - ): - dependencies.read_pins(content) - - def test_rejects_duplicate_declaration(self) -> None: - content = ( - dockerfile() - + f'\nENV PHP_REDIS_VERSION="{CURRENT["redis"]}"\n' - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Expected exactly one PHP_REDIS_VERSION declaration, found 2', - ): - dependencies.read_pins(content) - - def test_rejects_missing_and_duplicate_base_declarations(self) -> None: - missing = dockerfile().replace( - f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{OLD_DIGEST}"\n', - '', - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Expected exactly one ARG BASE_IMAGE declaration, found 0', - ): - dependencies.read_pins(missing) - - duplicate = ( - dockerfile() - + f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{OLD_DIGEST}"\n' - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Expected exactly one ARG BASE_IMAGE declaration, found 2', - ): - dependencies.read_pins(duplicate) - - def test_rejects_invalid_pin_spelling(self) -> None: - content = dockerfile().replace( - f'PHP_YAML_VERSION="{CURRENT["yaml"]}"', - 'PHP_YAML_VERSION="2.4.0RC1"', - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'PHP_YAML_VERSION must be an exact stable', - ): - dependencies.read_pins(content) - - def test_rejects_invalid_base_digest(self) -> None: - content = dockerfile().replace(OLD_DIGEST, 'sha256:ABC') - with self.assertRaisesRegex( - dependencies.UpdateError, - 'must pin php:8.5-alpine to a lowercase sha256 digest', - ): - dependencies.read_pins(content) - - def test_resolves_lowercase_multiarch_digest_through_buildx(self) -> None: - discovery = Discovery(digest=NEW_DIGEST) - self.assertEqual( - NEW_DIGEST, - dependencies.resolve_digest(discovery.run), - ) - self.assertEqual( - [ - ( - 'docker', - 'buildx', - 'imagetools', - 'inspect', - dependencies.BASE_NAME, - ) - ], - discovery.commands, - ) - - def test_rejects_invalid_or_ambiguous_digest_output(self) -> None: - invalid = lambda command: 'Digest: sha256:' + ('A' * 64) + '\n' - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Expected one lowercase sha256 digest', - ): - dependencies.resolve_digest(invalid) - - ambiguous = lambda command: ( - f'Digest: {OLD_DIGEST}\nDigest: {NEW_DIGEST}\n' - ) - with self.assertRaisesRegex( - dependencies.UpdateError, - 'Expected one lowercase sha256 digest', - ): - dependencies.resolve_digest(ambiguous) - - def test_plans_updates_without_touching_references(self) -> None: - discovery = Discovery( - digest=NEW_DIGEST, - releases={ - 'redis': (CURRENT['redis'], '6.3.1', 'v6.3.1', '7.0.0'), - 'swoole': (CURRENT['swoole'], '6.2.1', 'v6.2.1'), - }, - pecl=( - (CURRENT['protobuf'], 'stable'), - ('5.34.1', 'stable'), - ('5.35.0RC1', 'beta'), - ('6.0.0', 'stable'), - ), - ) - plan = dependencies.plan_update( - dockerfile(), - discovery.run, - discovery.fetch, - ) - self.assertTrue(plan.changed) - self.assertIn( - f'ARG BASE_IMAGE="{dependencies.BASE_NAME}@{NEW_DIGEST}"', - plan.content, - ) - self.assertIn('PHP_REDIS_VERSION="6.3.1"', plan.content) - self.assertIn('PHP_PROTOBUF_VERSION="5.34.1"', plan.content) - self.assertIn('PHP_SWOOLE_VERSION="v6.2.1"', plan.content) - self.assertIn('RUN echo "$PHP_REDIS_VERSION"', plan.content) - - def test_noop_plan_preserves_content_exactly(self) -> None: - content = dockerfile() - discovery = Discovery() - plan = dependencies.plan_update( - content, - discovery.run, - discovery.fetch, - ) - self.assertFalse(plan.changed) - self.assertEqual(content, plan.content) - - def test_injects_every_external_interaction(self) -> None: - discovery = Discovery() - dependencies.plan_update( - dockerfile(), - discovery.run, - discovery.fetch, - ) - git_sources = sum( - isinstance(dependency.source, dependencies.GitSource) - for dependency in dependencies.DEPENDENCIES - ) - self.assertEqual(1 + git_sources, len(discovery.commands)) - self.assertEqual([dependencies.PECL_RELEASES], discovery.urls) - for dependency in dependencies.DEPENDENCIES: - if not isinstance(dependency.source, dependencies.GitSource): - continue - self.assertIn( - ( - 'git', - 'ls-remote', - '--tags', - '--refs', - dependency.source.url, - ), - discovery.commands, - ) - - def test_dry_run_does_not_mutate_dockerfile_or_siblings(self) -> None: - discovery = Discovery( - digest=NEW_DIGEST, - releases={'redis': (CURRENT['redis'], '6.3.1')}, - ) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - path = root / 'Dockerfile' - sibling = root / 'keep.txt' - original = dockerfile() - path.write_text(original, encoding='utf-8') - sibling.write_text('unchanged', encoding='utf-8') - - plan = dependencies.update( - path, - dry_run=True, - runner=discovery.run, - fetcher=discovery.fetch, - ) - - self.assertTrue(plan.changed) - self.assertEqual(original, path.read_text(encoding='utf-8')) - self.assertEqual( - 'unchanged', - sibling.read_text(encoding='utf-8'), - ) - - def test_update_mutates_only_dockerfile(self) -> None: - discovery = Discovery(digest=NEW_DIGEST) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - path = root / 'Dockerfile' - sibling = root / 'keep.txt' - path.write_text(dockerfile(), encoding='utf-8') - sibling.write_text('unchanged', encoding='utf-8') - - plan = dependencies.update( - path, - runner=discovery.run, - fetcher=discovery.fetch, - ) - - self.assertEqual(plan.content, path.read_text(encoding='utf-8')) - self.assertEqual( - 'unchanged', - sibling.read_text(encoding='utf-8'), - ) - - def test_rejects_non_dockerfile_target(self) -> None: - with self.assertRaisesRegex( - dependencies.UpdateError, - 'target must be named Dockerfile', - ): - dependencies.update(Path('/tmp/not-a-dockerfile')) - - -class ReportTests(unittest.TestCase): - """Verify Markdown output for updates and no-op runs.""" - - def test_renders_markdown_update_report(self) -> None: - discovery = Discovery( - digest=NEW_DIGEST, - releases={'redis': (CURRENT['redis'], '6.3.1')}, - ) - plan = dependencies.plan_update( - dockerfile(), - discovery.run, - discovery.fetch, - ) - report = dependencies.render_report(plan) - self.assertTrue(report.startswith('## Dependency update report\n')) - self.assertIn( - '| Dependency | Current | Selected | Result |', - report, - ) - self.assertIn( - f'| {dependencies.BASE_NAME} | `{OLD_DIGEST}` ' - f'| `{NEW_DIGEST}` | Updated |', - report, - ) - self.assertIn( - f'| redis | `{CURRENT["redis"]}` | `6.3.1` | Updated |', - report, - ) - self.assertIn('**Updates:** 2', report) - self.assertTrue(report.endswith('Dockerfile pins were updated.')) - - def test_renders_explicit_noop_report(self) -> None: - discovery = Discovery() - plan = dependencies.plan_update( - dockerfile(), - discovery.run, - discovery.fetch, - ) - report = dependencies.render_report(plan) - self.assertIn('**Updates:** 0', report) - self.assertIn('No dependency updates were found.', report) - self.assertNotIn('| Updated |', report) - - -if __name__ == '__main__': - unittest.main() diff --git a/.github/scripts/test_orchestrator.py b/.github/scripts/test_orchestrator.py deleted file mode 100644 index c45619c..0000000 --- a/.github/scripts/test_orchestrator.py +++ /dev/null @@ -1,500 +0,0 @@ -import subprocess -import sys -import unittest -from pathlib import Path -from unittest.mock import Mock, call - - -sys.path.insert(0, str(Path(__file__).parent)) - -from automation import ( # noqa: E402 - HeadChangedError, - Merge, - PullRequestUnavailableError, - Recovery, - Tag, - TargetMismatchError, -) -from orchestrator import Orchestrator # noqa: E402 - - -class OrchestratorTest(unittest.TestCase): - def orchestrator(self) -> Orchestrator: - orchestrator = object.__new__(Orchestrator) - orchestrator.repository = 'appwrite/docker-base' - orchestrator.version = '2026-03-10' - orchestrator.header = 'X-GitHub-Api-Version: 2026-03-10' - return orchestrator - - def result( - self, - *, - returncode: int = 0, - output: str = '{}', - ) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess( - args=('gh',), - returncode=returncode, - stdout=output, - stderr='failure', - ) - - def release( - self, - *, - identifier: int, - tag: str, - target: str, - draft: bool, - prerelease: bool = False, - body: str = '', - ) -> dict[str, object]: - return { - 'id': identifier, - 'tag_name': tag, - 'target_commitish': target, - 'draft': draft, - 'prerelease': prerelease, - 'body': body, - } - - def pull( - self, - *, - head: str, - base: str, - state: str = 'OPEN', - commit: str | None = None, - ) -> dict[str, object]: - return { - 'baseRefName': 'main', - 'baseRefOid': base, - 'headRefOid': head, - 'mergeCommit': ( - {'oid': commit} - if commit is not None - else None - ), - 'mergeable': 'MERGEABLE', - 'number': 75, - 'reviewDecision': 'APPROVED', - 'state': state, - } - - def test_handles_missing_release_by_tag_as_none(self) -> None: - orchestrator = self.orchestrator() - orchestrator._api = Mock( - return_value=self.result( - returncode=1, - output='{"status":"404"}', - ) - ) - orchestrator._read_graphql_release = Mock(return_value=None) - - self.assertIsNone( - orchestrator._read_release_by_tag('1.4.5') - ) - - def test_finds_draft_after_release_by_tag_returns_404(self) -> None: - orchestrator = self.orchestrator() - draft = self.release( - identifier=10, - tag='1.4.5', - target='a' * 40, - draft=True, - ) - orchestrator._api = Mock( - return_value=self.result( - returncode=1, - output='{"status":"404"}', - ) - ) - orchestrator._read_graphql_release = Mock(return_value=draft) - - self.assertIs( - draft, - orchestrator._read_release_by_tag('1.4.5'), - ) - - def test_exact_lookup_finds_published_release_omitted_from_list( - self, - ) -> None: - orchestrator = self.orchestrator() - first = 'a' * 40 - second = 'b' * 40 - orchestrator._listed_releases = Mock( - return_value=( - Recovery( - identifier=9, - tag='1.4.3', - target=first, - pull=0, - draft=False, - prerelease=False, - body='', - ), - ) - ) - payloads = { - '1.4.3': self.release( - identifier=9, - tag='1.4.3', - target=first, - draft=False, - ), - '1.4.4': self.release( - identifier=10, - tag='1.4.4', - target=second, - draft=False, - ), - } - orchestrator._read_release_by_tag = Mock( - side_effect=payloads.get - ) - - releases = orchestrator._releases( - ( - Tag(name='1.4.3', target=first), - Tag(name='1.4.4', target=second), - ) - ) - - self.assertEqual( - {'1.4.3', '1.4.4'}, - {release.tag for release in releases}, - ) - orchestrator._read_release_by_tag.assert_has_calls( - [call('1.4.3'), call('1.4.4')] - ) - - def test_recovers_merge_cancelled_before_tag_on_next_no_diff_run( - self, - ) -> None: - orchestrator = self.orchestrator() - released = 'a' * 40 - target = 'b' * 40 - head = 'c' * 40 - base = 'd' * 40 - orchestrator._tags = Mock( - return_value=(Tag(name='1.4.4', target=released),) - ) - orchestrator._releases = Mock( - return_value=( - Recovery( - identifier=9, - tag='1.4.4', - target=released, - pull=0, - draft=False, - prerelease=False, - body='', - ), - ) - ) - orchestrator._merges = Mock( - return_value=( - Merge( - number=75, - target=target, - head=head, - parents=(base,), - base='main', - branch='automation/dependencies-100-1', - body=( - '\n' - f'\n' - f'' - ), - files=('Dockerfile',), - state='merged', - ), - ) - ) - orchestrator._write = Mock() - - orchestrator.recover() - - orchestrator._write.assert_called_once_with( - { - 'pending': 'true', - 'tag': '', - 'head': target, - 'pull': '75', - 'draft': '', - } - ) - - def test_resolves_recovery_commit_when_rest_merge_sha_is_null( - self, - ) -> None: - orchestrator = self.orchestrator() - head = 'a' * 40 - base = 'b' * 40 - commit = 'c' * 40 - body = ( - '\n' - f'\n' - f'' - ) - orchestrator._pages = Mock( - side_effect=[ - [ - { - 'number': 75, - 'merged_at': '2026-07-24T00:00:00Z', - 'merge_commit_sha': None, - 'head': { - 'ref': 'automation/dependencies-100-1', - 'sha': head, - }, - 'base': {'ref': 'main'}, - 'body': body, - } - ], - [{'filename': 'Dockerfile'}], - ] - ) - orchestrator._read_pull = Mock( - return_value=self.pull( - head=head, - base=base, - state='MERGED', - commit=commit, - ) - ) - orchestrator._read_parents = Mock(return_value=(base,)) - - self.assertEqual( - ( - Merge( - number=75, - target=commit, - head=head, - parents=(base,), - base='main', - branch='automation/dependencies-100-1', - body=body, - files=('Dockerfile',), - state='merged', - ), - ), - orchestrator._merges(), - ) - - def test_accepts_nonzero_merge_only_after_exact_remote_proof( - self, - ) -> None: - orchestrator = self.orchestrator() - head = 'a' * 40 - base = 'b' * 40 - commit = 'c' * 40 - orchestrator._read_pull = Mock( - side_effect=[ - self.pull(head=head, base=base), - self.pull( - head=head, - base=base, - state='MERGED', - commit=commit, - ), - ] - ) - orchestrator._run = Mock( - return_value=self.result(returncode=1) - ) - orchestrator._read_parents = Mock(return_value=(base,)) - orchestrator._write = Mock() - - orchestrator.merge(pull=75, head=head, base=base) - - orchestrator._write.assert_called_once_with({'head': commit}) - - def test_rejects_nonzero_merge_without_merged_remote_state( - self, - ) -> None: - orchestrator = self.orchestrator() - head = 'a' * 40 - base = 'b' * 40 - orchestrator._read_pull = Mock( - side_effect=[ - self.pull(head=head, base=base), - self.pull(head=head, base=base), - ] - ) - orchestrator._run = Mock( - return_value=self.result(returncode=1) - ) - orchestrator._write = Mock() - - with self.assertRaises(PullRequestUnavailableError): - orchestrator.merge(pull=75, head=head, base=base) - - orchestrator._write.assert_not_called() - - def test_rejects_merge_when_squash_parent_is_not_tested_base( - self, - ) -> None: - orchestrator = self.orchestrator() - head = 'a' * 40 - base = 'b' * 40 - commit = 'c' * 40 - orchestrator._read_pull = Mock( - side_effect=[ - self.pull(head=head, base=base), - self.pull( - head=head, - base=base, - state='MERGED', - commit=commit, - ), - ] - ) - orchestrator._run = Mock(return_value=self.result()) - orchestrator._read_parents = Mock( - return_value=('d' * 40,) - ) - orchestrator._write = Mock() - - with self.assertRaises(HeadChangedError): - orchestrator.merge(pull=75, head=head, base=base) - - orchestrator._write.assert_not_called() - - def test_existing_published_release_prevents_duplicate_draft( - self, - ) -> None: - orchestrator = self.orchestrator() - orchestrator._read_release_by_tag = Mock( - return_value=self.release( - identifier=10, - tag='1.4.5', - target='a' * 40, - draft=False, - ) - ) - orchestrator._api = Mock() - - with self.assertRaisesRegex(RuntimeError, 'already published'): - orchestrator._create_draft( - tag='1.4.5', - target='a' * 40, - pull=75, - ) - - orchestrator._api.assert_not_called() - - def test_existing_exact_draft_avoids_duplicate_create(self) -> None: - orchestrator = self.orchestrator() - target = 'a' * 40 - body = orchestrator._draft_body(target, 75) - orchestrator._read_release_by_tag = Mock( - return_value=self.release( - identifier=10, - tag='1.4.5', - target=target, - draft=True, - body=body, - ) - ) - orchestrator._api = Mock() - - self.assertEqual( - 10, - orchestrator._create_draft( - tag='1.4.5', - target=target, - pull=75, - ), - ) - orchestrator._api.assert_not_called() - - def test_recovers_concurrently_created_draft_after_422(self) -> None: - orchestrator = self.orchestrator() - target = 'a' * 40 - draft = self.release( - identifier=10, - tag='1.4.5', - target=target, - draft=True, - body=orchestrator._draft_body(target, 75), - ) - orchestrator._read_release_by_tag = Mock( - side_effect=[None, draft] - ) - orchestrator._api = Mock( - return_value=self.result(returncode=1) - ) - - self.assertEqual( - 10, - orchestrator._create_draft( - tag='1.4.5', - target=target, - pull=75, - ), - ) - - def test_does_not_publish_when_prepublication_target_changed(self) -> None: - orchestrator = self.orchestrator() - orchestrator._read_tag = Mock( - return_value=Tag(name='1.4.5', target='wrong') - ) - orchestrator._api = Mock() - - with self.assertRaises(TargetMismatchError): - orchestrator.publish( - tag='1.4.5', - target='expected', - pull=75, - draft=10, - ) - - orchestrator._api.assert_not_called() - - def test_returns_release_to_draft_when_postpublication_target_changed( - self, - ) -> None: - orchestrator = self.orchestrator() - orchestrator._read_tag = Mock( - side_effect=[ - Tag(name='1.4.5', target='expected'), - Tag(name='1.4.5', target='wrong'), - ] - ) - orchestrator._read_release = Mock( - side_effect=[ - {'draft': True}, - {'draft': True}, - ] - ) - orchestrator._validate_draft = Mock(return_value=10) - orchestrator._api = Mock( - side_effect=[ - self.result( - output=( - '{"tag_name":"1.4.5","draft":false,' - '"prerelease":false}' - ) - ), - self.result(), - ] - ) - - with self.assertRaisesRegex(RuntimeError, 'returned to draft'): - orchestrator.publish( - tag='1.4.5', - target='expected', - pull=75, - draft=10, - ) - - self.assertEqual( - call( - 'PATCH', - 'repos/appwrite/docker-base/releases/10', - (('-F', 'draft=true'),), - check=False, - ), - orchestrator._api.call_args_list[1], - ) diff --git a/.github/scripts/tests/Unit/ArchitectureTest.php b/.github/scripts/tests/Unit/ArchitectureTest.php new file mode 100644 index 0000000..a153d83 --- /dev/null +++ b/.github/scripts/tests/Unit/ArchitectureTest.php @@ -0,0 +1,163 @@ +getMessage()); + } + self::assertIsArray($equivalence); + self::assertSame( + [ + 'automation' => 43, + 'dependency' => 35, + 'adapter' => 13, + 'total' => 91, + 'status' => 'passed', + ], + $equivalence['baseline'] ?? null, + ); + self::assertSame( + [ + 'bytes' => 5477, + 'sha256' => '50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77', + 'status' => 'exact', + ], + $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::assertSame( + 'parity', + $this->value($contract, 'status'), + ); + + $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 + { + if (getenv('DOCKER_BASE_ALLOW_PYTHON') === '1') { + self::addToAssertionCount(1); + + return; + } + + $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), + ); + } + + /** + * @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/OrchestratorTest.php b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php index 4f6c547..73d558b 100644 --- a/.github/scripts/tests/Unit/Automation/OrchestratorTest.php +++ b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php @@ -4,6 +4,7 @@ namespace DockerBase\Tests\Unit\Automation; +use DateTimeImmutable; use DockerBase\Automation\Clock; use DockerBase\Automation\HeadChangedException; use DockerBase\Automation\Merge; @@ -16,6 +17,7 @@ use DockerBase\Automation\Repository; use DockerBase\Automation\Repository\GitHub; use DockerBase\Automation\ReviewDecision; +use DockerBase\Automation\Run; use DockerBase\Automation\Sleeper; use DockerBase\Automation\Tag; use DockerBase\Automation\TargetMismatchException; @@ -161,7 +163,7 @@ public function test_recovers_merge_cancelled_before_tag_on_next_no_diff_run(): $target = str_repeat('b', 40); $head = str_repeat('c', 40); $base = str_repeat('d', 40); - $repository = $this->repository(); + $repository = $this->createStub(Repository::class); $repository->method('tags')->willReturn([ new Tag('1.4.4', $released), ]); @@ -526,6 +528,88 @@ public function test_returns_release_to_draft_when_postpublication_target_change ); } + 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( 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/Dependency/ConsoleTest.php b/.github/scripts/tests/Unit/Dependency/ConsoleTest.php new file mode 100644 index 0000000..f6da55e --- /dev/null +++ b/.github/scripts/tests/Unit/Dependency/ConsoleTest.php @@ -0,0 +1,78 @@ +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(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Unknown dependency updater argument '--unknown'", + ); + + $this->console('/tmp/Dockerfile', new Discovery()) + ->execute(['--unknown']); + } + + 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/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/equivalence.json b/.github/scripts/tests/equivalence.json new file mode 100644 index 0000000..17f0d11 --- /dev/null +++ b/.github/scripts/tests/equivalence.json @@ -0,0 +1,744 @@ +{ + "baseline": { + "automation": 43, + "dependency": 35, + "adapter": 13, + "total": 91, + "status": "passed" + }, + "deterministic": { + "bytes": 5477, + "sha256": "50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77", + "status": "exact" + }, + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + }, + { + "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", + "status": "parity" + } + ] +} diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 22fe132..0705f30 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -29,25 +29,62 @@ jobs: token: ${{ github.token }} persist-credentials: false - - name: Run offline tests + - name: Preflight PHP runtime run: | - python3 -m unittest discover \ - --start-directory .github/scripts \ - --pattern 'test_*.py' \ - --verbose + # 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", + "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: python3 .github/scripts/orchestrator.py recover + 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" - python3 .github/scripts/dependencies.py | tee "${report}" + php .github/scripts/bin/dependencies.php | tee "${report}" cat "${report}" >> "${GITHUB_STEP_SUMMARY}" if git diff --quiet -- Dockerfile; then @@ -138,31 +175,10 @@ jobs: --json baseRefName,baseRefOid,createdAt,headRefName,headRefOid,number,state \ --jq '.' )" - PULL="${pull}" python3 - "${branch}" "${head}" "${base}" <<'PY' - import json - import os - import sys - - branch, head, base = sys.argv[1:] - pull = json.loads(os.environ['PULL']) - if pull['baseRefName'] != 'main': - raise RuntimeError('Pull request does not target main') - if pull['headRefName'] != branch or pull['headRefOid'] != head: - raise RuntimeError('Pull request does not use the pushed head') - if pull['baseRefOid'] != base: - raise RuntimeError('Pull request does not use the tested base') - if pull['state'] != 'OPEN': - raise RuntimeError('Pull request is not open') - PY - - { - printf 'number=%s\n' "$( - PULL="${pull}" python3 -c \ - 'import json, os; print(json.loads(os.environ["PULL"])["number"])' - )" - printf 'base=%s\n' "${base}" - printf 'url=%s\n' "${url}" - } >> "${GITHUB_OUTPUT}" + 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' @@ -172,146 +188,8 @@ jobs: GH_TOKEN: ${{ github.token }} HEAD: ${{ steps.push.outputs.head }} run: | - python3 - <<'PY' - import json - import os - import subprocess - import sys - import time - from datetime import datetime, timedelta, timezone - from pathlib import Path - - sys.path.insert(0, str(Path('.github/scripts').resolve())) - - from automation import ( - Deadline, - Run, - WorkflowState, - workflow_state, - ) - - branch = os.environ['BRANCH'] - boundary = datetime.fromisoformat( - os.environ['CREATED'].replace('Z', '+00:00') - ) - head = os.environ['HEAD'] - repository = os.environ['GITHUB_REPOSITORY'] - version = os.environ['GITHUB_API_VERSION'] - deadline = Deadline.after( - datetime.now(timezone.utc), - timedelta(minutes=120), - ) - 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'), - ) - terminal_failures = { - WorkflowState.CANCELLED, - WorkflowState.FAILED, - WorkflowState.TIMED_OUT, - } - - def fetch(filename: str) -> tuple[Run, ...]: - command = ( - 'gh', - 'api', - '-X', - 'GET', - ( - f'repos/{repository}/actions/workflows/' - f'{filename}/runs' - ), - '-H', - f'X-GitHub-Api-Version: {version}', - '-f', - f'branch={branch}', - '-f', - f'head_sha={head}', - '-f', - f'created=>={os.environ["CREATED"]}', - '-f', - 'per_page=100', - ) - result = subprocess.run( - command, - check=True, - stdout=subprocess.PIPE, - text=True, - ) - payload = json.loads(result.stdout) - return tuple( - Run( - identifier=item['id'], - workflow=item['name'], - event=item['event'], - head=item['head_sha'], - branch=item['head_branch'], - created=datetime.fromisoformat( - item['created_at'].replace('Z', '+00:00') - ), - attempt=item['run_attempt'], - status=item['status'], - conclusion=item['conclusion'], - ) - for item in payload['workflow_runs'] - ) - - previous = None - while True: - now = datetime.now(timezone.utc) - states = { - name: workflow_state( - fetch(filename), - workflow=name, - event=event, - head=head, - branch=branch, - created=boundary, - deadline=deadline, - now=now, - ) - for filename, name, event in workflows - } - snapshot = tuple( - (name, state.value) for name, state in states.items() - ) - if snapshot != previous: - print( - ', '.join( - f'{name}: {state}' for name, state in snapshot - ), - flush=True, - ) - previous = snapshot - - failed = { - name: state - for name, state in states.items() - if state in terminal_failures - } - if failed: - detail = ', '.join( - f'{name}: {state.value}' - for name, state in failed.items() - ) - raise RuntimeError(f'CI did not succeed: {detail}') - if all( - state is WorkflowState.SUCCEEDED - for state in states.values() - ): - break - - remaining = deadline.remaining( - datetime.now(timezone.utc) - ).total_seconds() - time.sleep(min(20, max(remaining, 0))) - PY + php .github/scripts/bin/orchestrator.php wait-checks \ + "${BRANCH}" "${HEAD}" "${CREATED}" - name: Approve pull request if: steps.update.outputs.changed == 'true' @@ -333,7 +211,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_TOKEN }} run: | - python3 .github/scripts/orchestrator.py merge \ + php .github/scripts/bin/orchestrator.php merge \ '${{ steps.pull.outputs.number }}' \ '${{ steps.push.outputs.head }}' \ '${{ steps.pull.outputs.base }}' @@ -352,7 +230,7 @@ jobs: ${{ steps.pull.outputs.number || steps.recover.outputs.pull }} TAG: ${{ steps.recover.outputs.tag }} run: | - python3 .github/scripts/orchestrator.py prepare \ + php .github/scripts/bin/orchestrator.php prepare \ "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" - name: Wait for exact tag Build and Push run @@ -362,7 +240,7 @@ jobs: HEAD: ${{ steps.release.outputs.head }} TAG: ${{ steps.release.outputs.tag }} run: | - python3 .github/scripts/orchestrator.py wait "${TAG}" "${HEAD}" + php .github/scripts/bin/orchestrator.php wait "${TAG}" "${HEAD}" - name: Verify and publish exact release if: steps.release.outcome == 'success' @@ -373,5 +251,5 @@ jobs: PULL: ${{ steps.release.outputs.pull }} TAG: ${{ steps.release.outputs.tag }} run: | - python3 .github/scripts/orchestrator.py publish \ + php .github/scripts/bin/orchestrator.php publish \ "${TAG}" "${HEAD}" "${PULL}" "${DRAFT}" From 371f997c55361ac3f29b783cbc679c88df504663 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 17:41:40 +1200 Subject: [PATCH 14/19] (fix): harden dependency release recovery --- .../scripts/src/Automation/Orchestrator.php | 6 +- .github/scripts/src/Automation/Repository.php | 4 +- .../src/Automation/Repository/GitHub.php | 56 ++++--- .github/scripts/src/Automation/Version.php | 82 ++++++---- .github/scripts/src/Command/Exception.php | 4 +- .github/scripts/src/Command/Failure.php | 20 +++ .../Unit/Automation/OrchestratorTest.php | 149 +++++++++++++----- .../tests/Unit/Automation/VersionTest.php | 59 +++++++ 8 files changed, 273 insertions(+), 107 deletions(-) create mode 100644 .github/scripts/src/Command/Failure.php diff --git a/.github/scripts/src/Automation/Orchestrator.php b/.github/scripts/src/Automation/Orchestrator.php index c4e523c..ea18bc0 100644 --- a/.github/scripts/src/Automation/Orchestrator.php +++ b/.github/scripts/src/Automation/Orchestrator.php @@ -39,9 +39,11 @@ public function __construct( public function recover(): ?Candidate { + $tags = $this->repository->tags(); + return RecoverySelector::select( - $this->repository->tags(), - $this->repository->releases(), + $tags, + $this->repository->releases($tags), $this->repository->mergedPullRequests(), ); } diff --git a/.github/scripts/src/Automation/Repository.php b/.github/scripts/src/Automation/Repository.php index e479143..535cc3c 100644 --- a/.github/scripts/src/Automation/Repository.php +++ b/.github/scripts/src/Automation/Repository.php @@ -14,9 +14,11 @@ public function tags(): array; public function releaseByTag(string $tag): ?Recovery; /** + * @param list $tags + * * @return list */ - public function releases(): array; + public function releases(array $tags): array; /** * @return list diff --git a/.github/scripts/src/Automation/Repository/GitHub.php b/.github/scripts/src/Automation/Repository/GitHub.php index aa71a43..ffa11df 100644 --- a/.github/scripts/src/Automation/Repository/GitHub.php +++ b/.github/scripts/src/Automation/Repository/GitHub.php @@ -14,6 +14,7 @@ use DockerBase\Automation\ReviewDecision; use DockerBase\Automation\Run; use DockerBase\Automation\Tag; +use DockerBase\Automation\Version; use DockerBase\Command\Result; use DockerBase\Command\Runner; use JsonException; @@ -100,39 +101,45 @@ public function releaseByTag(string $tag): ?Recovery } /** + * @param list $tags + * * @return list */ #[Override] - public function releases(): array + public function releases(array $tags): array { - $tags = $this->tags(); $stable = []; + $versions = []; foreach ($tags as $tag) { - if ($this->stable($tag->name)) { - $stable[$tag->name] = $tag; + $version = Version::parse($tag->name); + if ($version === null) { + continue; } + + $stable[$tag->name] = $tag; + $versions[$tag->name] = $version; } - $listed = $this->listedReleases(); + $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 => version_compare( - $right->tag, - $left->tag, + static fn (Recovery $left, Recovery $right): int => ( + $versions[$right->tag]->compare($versions[$left->tag]) ), ); $releases = []; $threshold = null; foreach ($listed as $hint) { - if ( - $hint->draft - || $hint->prerelease - || ! isset($stable[$hint->tag]) - ) { - continue; - } - $release = $this->releaseByTag($hint->tag); if ($release === null) { continue; @@ -140,7 +147,7 @@ public function releases(): array $this->assertTag($release, $hint->tag); $releases[$release->tag] = $release; if (! $release->draft && ! $release->prerelease) { - $threshold = $release->tag; + $threshold = $versions[$release->tag]; break; } } @@ -148,15 +155,14 @@ public function releases(): array $candidates = array_keys($stable); usort( $candidates, - static fn (string $left, string $right): int => version_compare( - $right, - $left, + static fn (string $left, string $right): int => ( + $versions[$right]->compare($versions[$left]) ), ); foreach ($candidates as $tag) { if ( $threshold !== null - && version_compare($tag, $threshold) <= 0 + && $versions[$tag]->compare($threshold) <= 0 ) { continue; } @@ -872,14 +878,6 @@ private function assertTag(Recovery $release, string $expected): void } } - private function stable(string $version): bool - { - return preg_match( - '/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/D', - $version, - ) === 1; - } - private function pullFromBody(string $body): int { if ( diff --git a/.github/scripts/src/Automation/Version.php b/.github/scripts/src/Automation/Version.php index 43557b1..0417a67 100644 --- a/.github/scripts/src/Automation/Version.php +++ b/.github/scripts/src/Automation/Version.php @@ -4,23 +4,28 @@ namespace DockerBase\Automation; +use Override; use Stringable; final readonly class Version implements Stringable { + private const string COMPONENT = '/\A(?:0|[1-9][0-9]*)\z/D'; + private const string PATTERN = '/\A(0|[1-9][0-9]*)\.' . '(0|[1-9][0-9]*)\.' . '(0|[1-9][0-9]*)\z/D'; public function __construct( - public int $major, - public int $minor, - public int $patch, + public string $major, + public string $minor, + public string $patch, ) { - if ($major < 0 || $minor < 0 || $patch < 0) { - throw new VersionInvalidException( - 'Version components cannot be negative', - ); + foreach ([$major, $minor, $patch] as $component) { + if (preg_match(self::COMPONENT, $component) !== 1) { + throw new VersionInvalidException( + 'Version components must be canonical non-negative integers', + ); + } } } @@ -31,14 +36,7 @@ public static function parse(string $value): ?self return null; } - $major = self::component($matches[1]); - $minor = self::component($matches[2]); - $patch = self::component($matches[3]); - if ($major === null || $minor === null || $patch === null) { - return null; - } - - return new self($major, $minor, $patch); + return new self($matches[1], $matches[2], $matches[3]); } /** @@ -173,35 +171,55 @@ public static function afterCollision( public function nextPatch(): self { - if ($this->patch === PHP_INT_MAX) { - throw new VersionInvalidException( - 'Patch version cannot be incremented', - ); - } - - return new self($this->major, $this->minor, $this->patch + 1); + return new self( + $this->major, + $this->minor, + self::increment($this->patch), + ); } public function compare(self $other): int { - return [$this->major, $this->minor, $this->patch] - <=> [$other->major, $other->minor, $other->patch]; + 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] + #[Override] public function __toString(): string { return "{$this->major}.{$this->minor}.{$this->patch}"; } - private static function component(string $value): ?int + private static function increment(string $value): string { - $component = filter_var( - $value, - FILTER_VALIDATE_INT, - ['options' => ['min_range' => 0]], - ); + $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 is_int($component) ? $component : null; + return '1' . $incremented; } } diff --git a/.github/scripts/src/Command/Exception.php b/.github/scripts/src/Command/Exception.php index c2489cd..31867ff 100644 --- a/.github/scripts/src/Command/Exception.php +++ b/.github/scripts/src/Command/Exception.php @@ -4,14 +4,16 @@ namespace DockerBase\Command; +use Override; use RuntimeException; use Throwable; -final class Exception extends RuntimeException +final class Exception extends RuntimeException implements Failure { /** * @param list $command */ + #[Override] public function __construct( public readonly array $command, public readonly ?Result $result = null, 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/tests/Unit/Automation/OrchestratorTest.php b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php index 73d558b..588f8e7 100644 --- a/.github/scripts/tests/Unit/Automation/OrchestratorTest.php +++ b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php @@ -90,19 +90,11 @@ public function test_exact_lookup_finds_published_release_omitted_from_list(): v { $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([ - [ - 'ref' => 'refs/tags/1.4.3', - 'object' => ['type' => 'commit', 'sha' => $first], - ], - [ - 'ref' => 'refs/tags/1.4.4', - 'object' => ['type' => 'commit', 'sha' => $second], - ], - ]), - ), $this->commandResult( output: $this->pages([ $this->release( @@ -137,7 +129,7 @@ public function test_exact_lookup_finds_published_release_omitted_from_list(): v ), ]); - $releases = $this->github($runner)->releases(); + $releases = $this->github($runner)->releases($tags); self::assertSame( ['1.4.3', '1.4.4'], @@ -149,11 +141,76 @@ public function test_exact_lookup_finds_published_release_omitted_from_list(): v self::assertSame(0, $runner->remaining()); self::assertStringEndsWith( '/releases/tags/1.4.3', - $runner->commands()[2]['command'][4], + $runner->commands()[1]['command'][4], ); self::assertStringEndsWith( '/releases/tags/1.4.4', - $runner->commands()[3]['command'][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], ); } @@ -163,34 +220,42 @@ public function test_recovers_merge_cancelled_before_tag_on_next_no_diff_run(): $target = str_repeat('b', 40); $head = str_repeat('c', 40); $base = str_repeat('d', 40); - $repository = $this->createStub(Repository::class); - $repository->method('tags')->willReturn([ + $tags = [ new Tag('1.4.4', $released), - ]); - $repository->method('releases')->willReturn([ - new Recovery( - 9, - '1.4.4', - $released, - 0, - false, - false, - '', - ), - ]); - $repository->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', - ), - ]); + ]; + $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(); diff --git a/.github/scripts/tests/Unit/Automation/VersionTest.php b/.github/scripts/tests/Unit/Automation/VersionTest.php index 02cb937..fc8dd4c 100644 --- a/.github/scripts/tests/Unit/Automation/VersionTest.php +++ b/.github/scripts/tests/Unit/Automation/VersionTest.php @@ -12,6 +12,19 @@ final class VersionTest extends TestCase { + #[Test] + public function test_parses_unbounded_canonical_components_as_strings(): void + { + $version = Version::parse( + '18446744073709551616.9223372036854775808.99999999999999999999', + ); + + self::assertNotNull($version); + self::assertSame('18446744073709551616', $version->major); + self::assertSame('9223372036854775808', $version->minor); + self::assertSame('99999999999999999999', $version->patch); + } + #[Test] public function test_orders_stable_versions_semantically(): void { @@ -29,6 +42,26 @@ public function test_orders_stable_versions_semantically(): void ); } + #[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 { @@ -66,6 +99,17 @@ public function test_next_patch_uses_semantic_maximum_of_all_remote_tags(): void ); } + #[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 { @@ -126,6 +170,21 @@ public function test_recomputes_after_collision_from_refreshed_remote_tags(): vo ); } + #[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 { From d2b5f37be8d578c51b57c8311b3267cc903b3a67 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 17:46:13 +1200 Subject: [PATCH 15/19] (fix): enforce PHP automation contracts --- .github/scripts/bin/dependencies.php | 28 +- .github/scripts/bin/orchestrator.php | 12 +- .github/scripts/bin/parity.php | 43 +++ .github/scripts/src/Dependency/Console.php | 19 +- .github/scripts/src/Dependency/Program.php | 81 +++++ .../scripts/src/Dependency/UsageException.php | 11 + .github/scripts/src/Parity/Exception.php | 11 + .github/scripts/src/Parity/Verifier.php | 170 +++++++++++ .../tests/E2E/Bin/DependenciesTest.php | 126 ++++++++ .../tests/E2E/Bin/Fixture/Dependencies.php | 54 ++++ .../tests/E2E/Bin/OrchestratorTest.php | 146 +++++++++ .github/scripts/tests/E2E/Bin/ParityTest.php | 128 ++++++++ .../scripts/tests/E2E/Command/ProcessTest.php | 6 +- .../scripts/tests/Unit/ArchitectureTest.php | 83 +++++- .../scripts/tests/Unit/Command/ResultTest.php | 4 +- .../tests/Unit/Dependency/ConsoleTest.php | 24 +- .../tests/Unit/Dependency/ProgramTest.php | 106 +++++++ .../tests/Unit/Parity/VerifierTest.php | 127 ++++++++ .github/scripts/tests/equivalence.json | 279 ++++++------------ .github/workflows/dependencies.yml | 1 + .gitignore | 1 + composer.json | 16 +- composer.lock | 12 +- 23 files changed, 1262 insertions(+), 226 deletions(-) create mode 100755 .github/scripts/bin/parity.php create mode 100644 .github/scripts/src/Dependency/Program.php create mode 100644 .github/scripts/src/Dependency/UsageException.php create mode 100644 .github/scripts/src/Parity/Exception.php create mode 100644 .github/scripts/src/Parity/Verifier.php create mode 100644 .github/scripts/tests/E2E/Bin/DependenciesTest.php create mode 100644 .github/scripts/tests/E2E/Bin/Fixture/Dependencies.php create mode 100644 .github/scripts/tests/E2E/Bin/OrchestratorTest.php create mode 100644 .github/scripts/tests/E2E/Bin/ParityTest.php create mode 100644 .github/scripts/tests/Unit/Dependency/ProgramTest.php create mode 100644 .github/scripts/tests/Unit/Parity/VerifierTest.php diff --git a/.github/scripts/bin/dependencies.php b/.github/scripts/bin/dependencies.php index f0e9dc9..1da08bd 100755 --- a/.github/scripts/bin/dependencies.php +++ b/.github/scripts/bin/dependencies.php @@ -7,6 +7,7 @@ use DockerBase\Dependency\Application; use DockerBase\Dependency\Console; use DockerBase\Dependency\Fetcher\HTTP; +use DockerBase\Dependency\Program; use DockerBase\Dependency\Reporter; use DockerBase\Dependency\Updater; @@ -14,15 +15,24 @@ require $root . '/vendor/autoload.php'; -$console = new Console( - new Updater( - Application::create( - new Process($root), - new HTTP(), +$result = (new Program( + new Console( + new Updater( + Application::create( + new Process($root), + new HTTP(), + ), ), + new Reporter(), + $root . '/Dockerfile', ), - new Reporter(), - $root . '/Dockerfile', -); +))->execute(array_slice($argv, 1)); -fwrite(STDOUT, $console->execute(array_slice($argv, 1)) . PHP_EOL); +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 index e07a933..6e8ffff 100755 --- a/.github/scripts/bin/orchestrator.php +++ b/.github/scripts/bin/orchestrator.php @@ -19,9 +19,13 @@ ?: throw new RuntimeException('GITHUB_REPOSITORY is required'); $version = getenv('GITHUB_API_VERSION') ?: throw new RuntimeException('GITHUB_API_VERSION is required'); -$input = stream_get_contents(STDIN); -if ($input === false) { - throw new RuntimeException('Unable to read standard input'); +$arguments = array_slice($argv, 1); +$input = ''; +if (($arguments[0] ?? null) === 'validate-pull') { + $input = stream_get_contents(STDIN); + if ($input === false) { + throw new RuntimeException('Unable to read standard input'); + } } $application = new Application( @@ -32,7 +36,7 @@ ), ); $output = (new WorkflowOutput( - $application->execute(array_slice($argv, 1), $input), + $application->execute($arguments, $input), ))->render(); if ($output !== '') { 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/Dependency/Console.php b/.github/scripts/src/Dependency/Console.php index f577076..38aaf34 100644 --- a/.github/scripts/src/Dependency/Console.php +++ b/.github/scripts/src/Dependency/Console.php @@ -4,10 +4,10 @@ namespace DockerBase\Dependency; -use InvalidArgumentException; - final readonly class Console { + public const string USAGE = 'Usage: dependencies.php [--dry-run] [--dockerfile PATH]'; + public function __construct( private Updater $updater, private Reporter $reporter, @@ -20,6 +20,15 @@ public function __construct( */ 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', + ); + } + $dockerfile = $this->dockerfile; $dryRun = false; @@ -33,7 +42,7 @@ public function execute(array $arguments): string if ($argument === '--dockerfile') { $dockerfile = $arguments[++$index] ?? ''; if ($dockerfile === '') { - throw new InvalidArgumentException( + throw new UsageException( '--dockerfile requires a path', ); } @@ -43,7 +52,7 @@ public function execute(array $arguments): string if (str_starts_with($argument, '--dockerfile=')) { $dockerfile = substr($argument, strlen('--dockerfile=')); if ($dockerfile === '') { - throw new InvalidArgumentException( + throw new UsageException( '--dockerfile requires a path', ); } @@ -51,7 +60,7 @@ public function execute(array $arguments): string continue; } - throw new InvalidArgumentException( + throw new UsageException( "Unknown dependency updater argument '{$argument}'", ); } 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/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 @@ +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 index c4c5e5c..ba61ccd 100644 --- a/.github/scripts/tests/E2E/Command/ProcessTest.php +++ b/.github/scripts/tests/E2E/Command/ProcessTest.php @@ -15,7 +15,7 @@ final class ProcessTest extends TestCase { public function testCapturesSuccessfulOutput(): void { - $result = new Process()->run([ + $result = (new Process())->run([ PHP_BINARY, '-r', 'fwrite(STDOUT, \'ready\');', @@ -28,7 +28,7 @@ public function testCapturesSuccessfulOutput(): void public function testReturnsAnUncheckedFailure(): void { - $result = new Process()->run( + $result = (new Process())->run( [ PHP_BINARY, '-r', @@ -45,7 +45,7 @@ public function testReturnsAnUncheckedFailure(): void public function testThrowsACheckedFailureWithItsResult(): void { try { - new Process()->run([ + (new Process())->run([ PHP_BINARY, '-r', 'fwrite(STDERR, \'failed\'); exit(9);', diff --git a/.github/scripts/tests/Unit/ArchitectureTest.php b/.github/scripts/tests/Unit/ArchitectureTest.php index a153d83..82b0b26 100644 --- a/.github/scripts/tests/Unit/ArchitectureTest.php +++ b/.github/scripts/tests/Unit/ArchitectureTest.php @@ -36,7 +36,6 @@ public function test_maps_every_python_contract_to_php(): void 'dependency' => 35, 'adapter' => 13, 'total' => 91, - 'status' => 'passed', ], $equivalence['baseline'] ?? null, ); @@ -44,7 +43,6 @@ public function test_maps_every_python_contract_to_php(): void [ 'bytes' => 5477, 'sha256' => '50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77', - 'status' => 'exact', ], $equivalence['deterministic'] ?? null, ); @@ -63,10 +61,7 @@ public function test_maps_every_python_contract_to_php(): void $group = $this->value($contract, 'group'); $this->value($contract, 'fixture'); $this->value($contract, 'assertion'); - self::assertSame( - 'parity', - $this->value($contract, 'status'), - ); + self::assertArrayNotHasKey('status', $contract); $python[] = $pythonContract; $php[] = $phpContract; @@ -100,12 +95,6 @@ class_exists($method[0]), public function test_contains_no_python_implementation_or_invocation(): void { - if (getenv('DOCKER_BASE_ALLOW_PYTHON') === '1') { - self::addToAssertionCount(1); - - return; - } - $root = dirname(__DIR__, 4); $python = []; $iterator = new RecursiveIteratorIterator( @@ -146,6 +135,76 @@ public function test_contains_no_python_implementation_or_invocation(): void ); } + 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 */ diff --git a/.github/scripts/tests/Unit/Command/ResultTest.php b/.github/scripts/tests/Unit/Command/ResultTest.php index dc67fcf..761c259 100644 --- a/.github/scripts/tests/Unit/Command/ResultTest.php +++ b/.github/scripts/tests/Unit/Command/ResultTest.php @@ -13,7 +13,7 @@ final class ResultTest extends TestCase { public function testReportsWhetherTheCommandSucceeded(): void { - self::assertTrue(new Result(0, 'output', '')->succeeded()); - self::assertFalse(new Result(1, '', 'error')->succeeded()); + self::assertTrue((new Result(0, 'output', ''))->succeeded()); + self::assertFalse((new Result(1, '', 'error'))->succeeded()); } } diff --git a/.github/scripts/tests/Unit/Dependency/ConsoleTest.php b/.github/scripts/tests/Unit/Dependency/ConsoleTest.php index f6da55e..23281de 100644 --- a/.github/scripts/tests/Unit/Dependency/ConsoleTest.php +++ b/.github/scripts/tests/Unit/Dependency/ConsoleTest.php @@ -12,7 +12,7 @@ use DockerBase\Dependency\Resolver; use DockerBase\Dependency\Selector; use DockerBase\Dependency\Updater; -use InvalidArgumentException; +use DockerBase\Dependency\UsageException; use PHPUnit\Framework\TestCase; final class ConsoleTest extends TestCase @@ -48,7 +48,7 @@ public function test_dispatches_a_dry_run_without_mutation(): void public function test_rejects_unknown_arguments(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(UsageException::class); $this->expectExceptionMessage( "Unknown dependency updater argument '--unknown'", ); @@ -57,6 +57,26 @@ public function test_rejects_unknown_arguments(): void ->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, 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/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 index 17f0d11..df8d930 100644 --- a/.github/scripts/tests/equivalence.json +++ b/.github/scripts/tests/equivalence.json @@ -3,13 +3,11 @@ "automation": 43, "dependency": 35, "adapter": 13, - "total": 91, - "status": "passed" + "total": 91 }, "deterministic": { "bytes": 5477, - "sha256": "50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77", - "status": "exact" + "sha256": "50736031df39f38434d6c09b455aa651e886a6a79319c951b631df8a49a93a77" }, "contracts": [ { @@ -17,728 +15,637 @@ "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "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", - "status": "parity" + "assertion": "same value, exception, bytes, mutation, and status contract" } ] } diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 0705f30..084e3a9 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -43,6 +43,7 @@ jobs: "json", "libxml", "mbstring", + "openssl", "phar", "tokenizer", "xml", diff --git a/.gitignore b/.gitignore index 0d4fde3..f76a5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea /.phpstan-cache/ /.phpunit.cache/ +/.phpunit.results.xml /vendor/ diff --git a/composer.json b/composer.json index fc23e49..980e375 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,16 @@ "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" }, "require-dev": { @@ -32,11 +42,13 @@ "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint", "check": "vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G", - "test": "vendor/bin/phpunit --configuration=phpunit.xml", + "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" + "@test", + "@parity" ] } } diff --git a/composer.lock b/composer.lock index 5ff9a4a..9604c0e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "06137fbc2966a06bcc49b061cf62ff0b", + "content-hash": "52aa8f2f654454ce556fc558b61b3c05", "packages": [], "packages-dev": [ { @@ -1827,6 +1827,16 @@ "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": {}, From 8067736b009990f61deed2607bc96d394268fea7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 18:07:40 +1200 Subject: [PATCH 16/19] (fix): scan hidden PHP files with Pint --- .github/scripts/src/Dependency/Resolver.php | 4 ++-- .github/scripts/tests/Unit/Automation/OrchestratorTest.php | 3 --- composer.json | 4 ++-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/scripts/src/Dependency/Resolver.php b/.github/scripts/src/Dependency/Resolver.php index 7d55218..2e98488 100644 --- a/.github/scripts/src/Dependency/Resolver.php +++ b/.github/scripts/src/Dependency/Resolver.php @@ -4,12 +4,12 @@ namespace DockerBase\Dependency; -use DOMDocument; -use DOMElement; use DockerBase\Command\Exception as CommandException; use DockerBase\Command\Runner; use DockerBase\Dependency\Source\Git; use DockerBase\Dependency\Source\PECL; +use DOMDocument; +use DOMElement; final readonly class Resolver { diff --git a/.github/scripts/tests/Unit/Automation/OrchestratorTest.php b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php index 588f8e7..d338c8a 100644 --- a/.github/scripts/tests/Unit/Automation/OrchestratorTest.php +++ b/.github/scripts/tests/Unit/Automation/OrchestratorTest.php @@ -8,15 +8,12 @@ use DockerBase\Automation\Clock; use DockerBase\Automation\HeadChangedException; use DockerBase\Automation\Merge; -use DockerBase\Automation\MergeResult; use DockerBase\Automation\Orchestrator; use DockerBase\Automation\Preparation; -use DockerBase\Automation\PullRequest; use DockerBase\Automation\PullRequestUnavailableException; use DockerBase\Automation\Recovery; use DockerBase\Automation\Repository; use DockerBase\Automation\Repository\GitHub; -use DockerBase\Automation\ReviewDecision; use DockerBase\Automation\Run; use DockerBase\Automation\Sleeper; use DockerBase\Automation\Tag; diff --git a/composer.json b/composer.json index 980e375..0c70a0a 100644 --- a/composer.json +++ b/composer.json @@ -39,8 +39,8 @@ "sort-packages": true }, "scripts": { - "lint": "vendor/bin/pint --test", - "format": "vendor/bin/pint", + "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", From 27d9c21d201f5db6af8cd29ad04d73ee5bd60eb3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 19:01:59 +1200 Subject: [PATCH 17/19] refactor: use utopia cli and console libraries --- .github/scripts/src/Command/Process.php | 90 ++----- .github/scripts/src/Dependency/Console.php | 67 ++--- composer.json | 4 +- composer.lock | 284 ++++++++++++++++++++- 4 files changed, 345 insertions(+), 100 deletions(-) diff --git a/.github/scripts/src/Command/Process.php b/.github/scripts/src/Command/Process.php index bacdc64..378e6ed 100644 --- a/.github/scripts/src/Command/Process.php +++ b/.github/scripts/src/Command/Process.php @@ -5,93 +5,49 @@ namespace DockerBase\Command; use Override; +use Utopia\Console; final readonly class Process implements Runner { - /** - * @param array|null $environment - */ - public function __construct( - private ?string $directory = null, - private ?array $environment = null, - ) { + /** @param array|null $environment */ + public function __construct(private ?string $directory = null, private ?array $environment = null) + { } - /** - * @param list $command - */ + /** @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', - ); + throw new Exception($command, message: 'A command must contain non-empty arguments'); } - $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', - ); + $stdout = ''; + $stderr = ''; + $previous = getcwd(); + $environment = []; + foreach ($this->environment ?? [] as $key => $value) { + $environment[$key] = getenv($key); + putenv($key . '=' . $value); } try { - $pipes = []; - $process = proc_open( - $command, - [ - 0 => $input, - 1 => $output, - 2 => $error, - ], - $pipes, - $this->directory, - $this->environment, - ['bypass_shell' => true], - ); - - if (! is_resource($process)) { - throw new Exception( - $command, - message: 'Unable to start command', - ); - } - - $code = proc_close($process); - rewind($output); - rewind($error); - $standardOutput = stream_get_contents($output); - $standardError = stream_get_contents($error); - - if ($standardOutput === false || $standardError === false) { - throw new Exception( - $command, - message: 'Unable to read command output', - ); + if ($this->directory !== null && ! chdir($this->directory)) { + throw new Exception($command, message: 'Unable to start command'); } - - $result = new Result($code, $standardOutput, $standardError); + $code = Console::execute($command, '', $stdout, $stderr); + $result = new Result($code, $stdout, $stderr); if ($check && ! $result->succeeded()) { throw new Exception($command, $result); } - return $result; } finally { - fclose($input); - fclose($output); - fclose($error); + if ($previous !== false) { + chdir($previous); + } + foreach ($environment as $key => $value) { + putenv($key . ($value === false ? '' : '=' . $value)); + } } } } diff --git a/.github/scripts/src/Dependency/Console.php b/.github/scripts/src/Dependency/Console.php index 38aaf34..e492045 100644 --- a/.github/scripts/src/Dependency/Console.php +++ b/.github/scripts/src/Dependency/Console.php @@ -4,6 +4,9 @@ namespace DockerBase\Dependency; +use Throwable; +use Utopia\CLI\CLI; + final readonly class Console { public const string USAGE = 'Usage: dependencies.php [--dry-run] [--dockerfile PATH]'; @@ -29,44 +32,48 @@ public function execute(array $arguments): string ); } - $dockerfile = $this->dockerfile; - $dryRun = false; - + $normalized = ['dependencies.php', 'update']; for ($index = 0; $index < count($arguments); ++$index) { $argument = $arguments[$index]; if ($argument === '--dry-run') { - $dryRun = true; - - continue; - } - if ($argument === '--dockerfile') { - $dockerfile = $arguments[++$index] ?? ''; - if ($dockerfile === '') { - throw new UsageException( - '--dockerfile requires a path', - ); + $normalized[] = '--dry-run=true'; + } elseif ($argument === '--dockerfile') { + $path = $arguments[++$index] ?? ''; + if ($path === '') { + throw new UsageException('--dockerfile requires a path'); } - - continue; - } - if (str_starts_with($argument, '--dockerfile=')) { - $dockerfile = substr($argument, strlen('--dockerfile=')); - if ($dockerfile === '') { - 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'); } - - continue; + $normalized[] = $argument; + } else { + throw new UsageException("Unknown dependency updater argument '{$argument}'"); } + } - 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 $this->reporter->render( - $this->updater->update($dockerfile, $dryRun), - ); + return $result; } } diff --git a/composer.json b/composer.json index 0c70a0a..6fb5634 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,9 @@ "ext-tokenizer": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "php": ">=8.3" + "php": ">=8.3", + "utopia-php/cli": "0.*", + "utopia-php/console": "0.2.*" }, "require-dev": { "laravel/pint": "1.*", diff --git a/composer.lock b/composer.lock index 9604c0e..93ad1f0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,288 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "52aa8f2f654454ce556fc558b61b3c05", - "packages": [], + "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", From b818b6809144c78329c92579b60a9e237d2e5199 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 19:08:16 +1200 Subject: [PATCH 18/19] fix: preserve explicit process environments --- .github/scripts/src/Command/Process.php | 53 ++++++++++++++----- .../scripts/tests/E2E/Command/ProcessTest.php | 17 ++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/scripts/src/Command/Process.php b/.github/scripts/src/Command/Process.php index 378e6ed..c50b690 100644 --- a/.github/scripts/src/Command/Process.php +++ b/.github/scripts/src/Command/Process.php @@ -22,32 +22,59 @@ public function run(array $command, bool $check = true): Result throw new Exception($command, message: 'A command must contain non-empty arguments'); } + if ($this->environment !== null) { + return $this->runWithEnvironment($command, $check); + } + $stdout = ''; $stderr = ''; - $previous = getcwd(); - $environment = []; - foreach ($this->environment ?? [] as $key => $value) { - $environment[$key] = getenv($key); - putenv($key . '=' . $value); + $code = Console::execute($command, '', $stdout, $stderr); + $result = new Result($code, $stdout, $stderr); + if ($check && ! $result->succeeded()) { + throw new Exception($command, $result); + } + + return $result; + } + + /** @param list $command */ + private function runWithEnvironment(array $command, bool $check): Result + { + $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 { - if ($this->directory !== null && ! chdir($this->directory)) { + $pipes = []; + $process = proc_open($command, [0 => $input, 1 => $output, 2 => $error], $pipes, $this->directory, $this->environment, ['bypass_shell' => true]); + if (! is_resource($process)) { throw new Exception($command, message: 'Unable to start command'); } - $code = Console::execute($command, '', $stdout, $stderr); + $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 { - if ($previous !== false) { - chdir($previous); - } - foreach ($environment as $key => $value) { - putenv($key . ($value === false ? '' : '=' . $value)); - } + fclose($input); + fclose($output); + fclose($error); } } } diff --git a/.github/scripts/tests/E2E/Command/ProcessTest.php b/.github/scripts/tests/E2E/Command/ProcessTest.php index ba61ccd..c710b82 100644 --- a/.github/scripts/tests/E2E/Command/ProcessTest.php +++ b/.github/scripts/tests/E2E/Command/ProcessTest.php @@ -61,4 +61,21 @@ public function testThrowsACheckedFailureWithItsResult(): void 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); + } } From d2eb21c9e24444f25b75a7418e8ff59cd75eb75c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 24 Jul 2026 19:12:03 +1200 Subject: [PATCH 19/19] fix: preserve process working directory --- .github/scripts/src/Command/Process.php | 26 +++++++--- .../scripts/tests/E2E/Command/ProcessTest.php | 50 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/.github/scripts/src/Command/Process.php b/.github/scripts/src/Command/Process.php index c50b690..f7c370e 100644 --- a/.github/scripts/src/Command/Process.php +++ b/.github/scripts/src/Command/Process.php @@ -22,14 +22,11 @@ public function run(array $command, bool $check = true): Result throw new Exception($command, message: 'A command must contain non-empty arguments'); } - if ($this->environment !== null) { + if ($this->environment !== null || $this->directory !== null) { return $this->runWithEnvironment($command, $check); } - $stdout = ''; - $stderr = ''; - $code = Console::execute($command, '', $stdout, $stderr); - $result = new Result($code, $stdout, $stderr); + $result = $this->executeConsole($command); if ($check && ! $result->succeeded()) { throw new Exception($command, $result); } @@ -37,9 +34,23 @@ public function run(array $command, bool $check = true): 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(); @@ -56,7 +67,10 @@ private function runWithEnvironment(array $command, bool $check): Result $pipes = []; $process = proc_open($command, [0 => $input, 1 => $output, 2 => $error], $pipes, $this->directory, $this->environment, ['bypass_shell' => true]); if (! is_resource($process)) { - throw new Exception($command, message: 'Unable to start command'); + $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); diff --git a/.github/scripts/tests/E2E/Command/ProcessTest.php b/.github/scripts/tests/E2E/Command/ProcessTest.php index c710b82..96fa40d 100644 --- a/.github/scripts/tests/E2E/Command/ProcessTest.php +++ b/.github/scripts/tests/E2E/Command/ProcessTest.php @@ -78,4 +78,54 @@ public function testExplicitEnvironmentReplacesInheritedEnvironment(): void 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";']); + } }