diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 0000000..c4bdef4 --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,236 @@ +name: Build Logic4 QA beta + +on: + workflow_dispatch: + inputs: + publish: + description: Publish a beta tag and GitHub prerelease + required: true + default: false + type: boolean + base_version: + description: SemVer base such as 4.1.0; defaults to the BETA_BASE_VERSION repository variable + required: false + type: string + schedule: + - cron: '0 8 * * *' + +permissions: {} + +concurrency: + group: logic4-qa-beta + cancel-in-progress: true + +jobs: + generate: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + base_sha: ${{ steps.generate.outputs.base_sha }} + has_changes: ${{ steps.generate.outputs.has_changes }} + qa_version: ${{ steps.generate.outputs.qa_version }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' + coverage: pcov + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Generate QA client + id: generate + shell: bash + run: | + set -euo pipefail + + composer generate-beta-endpoints + git add --all src + + echo "base_sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + + if git diff --cached --quiet -- src; then + echo "has_changes=false" >> "${GITHUB_OUTPUT}" + echo "## Logic4 QA beta" >> "${GITHUB_STEP_SUMMARY}" + echo "No generated source changes were found." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + qa_version="$(jq -er '.info.version' logic4-api-qa-V30.json)" + if [[ ! "${qa_version}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "Unexpected Logic4 QA version: ${qa_version}" >&2 + exit 1 + fi + + echo "has_changes=true" >> "${GITHUB_OUTPUT}" + echo "qa_version=${qa_version}" >> "${GITHUB_OUTPUT}" + + - name: Run quality gates + if: steps.generate.outputs.has_changes == 'true' + run: composer test + + - name: Prepare beta candidate + if: steps.generate.outputs.has_changes == 'true' + env: + QA_VERSION: ${{ steps.generate.outputs.qa_version }} + shell: bash + run: | + set -euo pipefail + + if ! git diff --quiet -- . ':(exclude)src/**'; then + echo "Generation changed files outside src:" >&2 + git diff --name-only -- . ':(exclude)src/**' >&2 + exit 1 + fi + + git add --all src + git diff --cached --check + git diff --cached --binary -- src > beta.patch + sha256sum beta.patch > beta.patch.sha256 + + jq -n \ + --arg base_sha "${GITHUB_SHA}" \ + --arg qa_version "${QA_VERSION}" \ + --arg source_url "https://qa.api.logic4server.nl/scalar/#latest-version" \ + '{base_sha: $base_sha, qa_version: $qa_version, source_url: $source_url}' \ + > beta-metadata.json + + echo "## Logic4 QA beta candidate" >> "${GITHUB_STEP_SUMMARY}" + echo "QA build: \`${QA_VERSION}\`" >> "${GITHUB_STEP_SUMMARY}" + echo "Base commit: \`${GITHUB_SHA}\`" >> "${GITHUB_STEP_SUMMARY}" + echo '```text' >> "${GITHUB_STEP_SUMMARY}" + git diff --cached --stat -- src >> "${GITHUB_STEP_SUMMARY}" + echo '```' >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload beta candidate + if: steps.generate.outputs.has_changes == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: logic4-qa-beta-${{ github.run_id }} + path: | + beta.patch + beta.patch.sha256 + beta-metadata.json + if-no-files-found: error + retention-days: 7 + + release: + if: needs.generate.outputs.has_changes == 'true' && github.event_name == 'workflow_dispatch' && inputs.publish == true && github.ref == 'refs/heads/master' + needs: generate + runs-on: ubuntu-latest + environment: beta-release + permissions: + actions: read + contents: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.generate.outputs.base_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' + coverage: pcov + + - name: Download beta candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: logic4-qa-beta-${{ github.run_id }} + path: .beta-artifact + + - name: Verify beta candidate + working-directory: .beta-artifact + run: sha256sum --check beta.patch.sha256 + + - name: Apply beta candidate + run: git apply --index .beta-artifact/beta.patch + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run quality gates + run: | + composer validate --strict + composer test + git diff --exit-code + git diff --cached --check + + - name: Publish beta release + id: release + env: + DEFAULT_BASE_VERSION: ${{ vars.BETA_BASE_VERSION }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + QA_VERSION: ${{ needs.generate.outputs.qa_version }} + REQUESTED_BASE_VERSION: ${{ inputs.base_version }} + shell: bash + run: | + set -euo pipefail + + base_version="${REQUESTED_BASE_VERSION:-${DEFAULT_BASE_VERSION:-}}" + if [[ ! "${base_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Set a valid base_version input or BETA_BASE_VERSION repository variable." >&2 + exit 1 + fi + + git fetch --force --tags origin + latest_beta_tag="$(git tag --list "v${base_version}-beta.*" --sort=-v:refname | head -n 1)" + + if [[ -n "${latest_beta_tag}" ]] && git diff --quiet "${latest_beta_tag}" -- src; then + echo "The generated source already matches ${latest_beta_tag}." + echo "published=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + sequence=0 + while IFS= read -r existing_tag; do + suffix="${existing_tag##*.}" + if [[ "${suffix}" =~ ^[0-9]+$ ]] && (( suffix > sequence )); then + sequence="${suffix}" + fi + done < <(git tag --list "v${base_version}-beta.*") + + tag="v${base_version}-beta.$((sequence + 1))" + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m "Generate Logic4 QA client for ${tag}" + git tag --annotate "${tag}" --message "${tag}" + + basic_auth="$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 -w0)" + trap 'git config --local --unset-all http.https://github.com/.extraheader || true' EXIT + git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic ${basic_auth}" + git push origin "refs/tags/${tag}" + git config --local --unset-all http.https://github.com/.extraheader + trap - EXIT + + { + echo "Generated from Logic4 QA build \`${QA_VERSION}\`." + echo + echo "Base commit: \`${GITHUB_SHA}\`" + echo "Documentation: https://qa.api.logic4server.nl/scalar/#latest-version" + } > beta-release-notes.md + + gh release create "${tag}" .beta-artifact/beta-metadata.json \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --prerelease \ + --title "${tag}" \ + --notes-file beta-release-notes.md + + echo "published=true" >> "${GITHUB_OUTPUT}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "Published ${tag}." >> "${GITHUB_STEP_SUMMARY}" diff --git a/README.md b/README.md index 1f629e8..550044e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ The endpoints can automatically be generated using the `generate` command. This make generate ``` +Production documentation remains the default source. To generate the client from the Logic4 QA documentation instead, run: + +```bash +composer generate-beta-endpoints +``` + +### Beta releases + +The `Build Logic4 QA beta` workflow checks the QA documentation daily. Scheduled runs only build, test, and upload a beta candidate when the generated client differs from `master`; they cannot publish a package. + +Publishing requires a manual workflow run from `master` with `publish` enabled, a semantic `base_version` such as `4.1.0` or the `BETA_BASE_VERSION` repository variable, and approval through the `beta-release` GitHub environment. A successful run creates a tag such as `v4.1.0-beta.1` and marks its GitHub release as a prerelease. + +Install a specific beta with: + +```bash +composer require webparking/logic4-client:4.1.0-beta.1 +``` + ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. - diff --git a/composer.json b/composer.json index de5cf78..fdf6f5b 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,12 @@ "composer check-coverage" ], "generate-endpoints": [ - "@php generate.php", + "@php generate.php --source=production", + "vendor/bin/rector process --no-diffs --ansi --memory-limit=2G", + "vendor/bin/php-cs-fixer fix" + ], + "generate-beta-endpoints": [ + "@php generate.php --source=qa --refresh", "vendor/bin/rector process --no-diffs --ansi --memory-limit=2G", "vendor/bin/php-cs-fixer fix" ] diff --git a/generate.php b/generate.php index 4d89c4b..6f182db 100644 --- a/generate.php +++ b/generate.php @@ -4,6 +4,7 @@ use Symfony\Component\ErrorHandler\Debug; use Symfony\Component\ErrorHandler\ErrorHandler; +use Webparking\Logic4Client\Generator\ApiDocumentationSource; use Webparking\Logic4Client\Generator\Generator; require_once __DIR__.'/vendor/autoload.php'; @@ -11,18 +12,34 @@ Debug::enable(); ErrorHandler::register(); -$refresh = in_array('--refresh', $argv ?? [], true); +$options = getopt('', ['refresh', 'source:']); + +if (false === $options) { + fwrite(\STDERR, "Could not parse generator options.\n"); + + exit(1); +} + +$sourceName = $options['source'] ?? ApiDocumentationSource::Production->value; + +if (!is_string($sourceName) || null === ($source = ApiDocumentationSource::tryFrom($sourceName))) { + fwrite(\STDERR, "Source must be either production or qa.\n"); + + exit(1); +} + +$refresh = array_key_exists('refresh', $options); +$generator = new Generator($source); $versions = array_filter( - Generator::resolveVersions(), + $generator->resolveVersions(), static fn (string $version): bool => Generator::GENERATED_MAJOR_VERSION === explode('.', $version, 2)[0], \ARRAY_FILTER_USE_KEY ); foreach ($versions as $version => $url) { - echo "Generating API namespace $version...\n"; + echo "Generating {$source->value} API namespace $version...\n"; - $generator = new Generator(); $generator->setRefresh($refresh); $generator->generate($version, $url); } diff --git a/generator/ApiDocumentationSource.php b/generator/ApiDocumentationSource.php new file mode 100644 index 0000000..3acb493 --- /dev/null +++ b/generator/ApiDocumentationSource.php @@ -0,0 +1,33 @@ +baseUrl().'scalar/'; + } + + public function openApiUrl(string $path): string + { + Assert::regex($path, '/\Aopenapi\/[a-z0-9]+(?:\.[a-z0-9]+)*\.json\z/D'); + + return $this->baseUrl().$path; + } + + private function baseUrl(): string + { + return match ($this) { + self::Production => 'https://api.logic4server.nl/', + self::Qa => 'https://qa.api.logic4server.nl/', + }; + } +} diff --git a/generator/Generator.php b/generator/Generator.php index dbc3977..3e807f0 100644 --- a/generator/Generator.php +++ b/generator/Generator.php @@ -11,13 +11,11 @@ use Webmozart\Assert\Assert; use Webparking\Logic4Client\Enums\PaginateType; -class Generator +final class Generator { public const string GENERATED_MAJOR_VERSION = '3'; - public static string $scalarUrl = 'https://api.logic4server.nl/scalar/'; - public static string $baseUrl = 'https://api.logic4server.nl/'; - public string $localApi = __DIR__.'/../logic4-api-%s.json'; + public string $localApi = __DIR__.'/../logic4-api-%s-%s.json'; public string $baseDirectory = __DIR__.'/../src/'; public string $baseNamespace = 'Webparking\Logic4Client'; @@ -28,6 +26,10 @@ class Generator private bool $setupHasRun = false; private bool $refresh = false; + public function __construct(private readonly ApiDocumentationSource $documentationSource) + { + } + public function setup(): void { if (!$this->setupHasRun) { @@ -39,25 +41,36 @@ public function setup(): void } /** @return array version => url */ - public static function resolveVersions(): array + public function resolveVersions(): array { - $contents = file_get_contents(self::$scalarUrl); + $contents = $this->download($this->documentationSource->scalarUrl()); - Assert::string($contents, 'Could not fetch API documentation'); + return $this->resolveVersionsFromScalar($contents); + } + /** @return array version => url */ + public function resolveVersionsFromScalar(string $contents): array + { preg_match('/initialize\([^,]+,\s*\w+,\s*(\{.+\})\s*,\s*\'/s', $contents, $matches); Assert::keyExists($matches, 1, 'Could not find Scalar configuration'); $config = json_decode($matches[1], true, 512, \JSON_THROW_ON_ERROR); + Assert::isArray($config, 'Invalid Scalar configuration'); Assert::keyExists($config, 'sources', 'Could not find API sources'); + Assert::isArray($config['sources'], 'Invalid Scalar API sources'); $versions = []; foreach ($config['sources'] as $source) { - if (preg_match('/Version v(\d+\.\d+)/', $source['title'], $versionMatch) - ) { - $versions[$versionMatch[1]] = self::$baseUrl.$source['url']; + Assert::isArray($source, 'Invalid Scalar API source'); + Assert::keyExists($source, 'title', 'Scalar API source has no title'); + Assert::keyExists($source, 'url', 'Scalar API source has no URL'); + Assert::string($source['title'], 'Invalid Scalar API source title'); + Assert::string($source['url'], 'Invalid Scalar API source URL'); + + if (preg_match('/Version v(\d+\.\d+)/', $source['title'], $versionMatch)) { + $versions[$versionMatch[1]] = $this->documentationSource->openApiUrl($source['url']); } } @@ -107,14 +120,18 @@ public function generate(string $version, string $remoteUrl): void public function downloadApiDocumentation(string $version, string $remoteUrl): string { - $localFile = \sprintf($this->localApi, $this->getVersion($version)); + $localFile = \sprintf( + $this->localApi, + $this->documentationSource->value, + $this->getVersion($version), + ); if ($this->refresh && is_file($localFile)) { unlink($localFile); } if (!is_file($localFile)) { - file_put_contents($localFile, file_get_contents($remoteUrl)); + file_put_contents($localFile, $this->download($remoteUrl)); } return $localFile; @@ -219,4 +236,21 @@ public function setRefresh(bool $refresh): void { $this->refresh = $refresh; } + + private function download(string $url): string + { + $context = stream_context_create([ + 'http' => [ + 'follow_location' => 0, + 'timeout' => 30, + 'user_agent' => 'webparking/logic4-client-generator', + ], + ]); + + $contents = file_get_contents($url, false, $context); + + Assert::string($contents, \sprintf('Could not fetch API documentation from %s', $url)); + + return $contents; + } } diff --git a/tests/Feature/GeneratorTest.php b/tests/Feature/GeneratorTest.php index 8471ed8..a204ba8 100644 --- a/tests/Feature/GeneratorTest.php +++ b/tests/Feature/GeneratorTest.php @@ -4,20 +4,53 @@ namespace Webparking\Logic4Client\Tests\Feature; +use Webparking\Logic4Client\Generator\ApiDocumentationSource; use Webparking\Logic4Client\Generator\Generator; use Webparking\Logic4Client\Tests\TestCase; final class GeneratorTest extends TestCase { - public function testCanDetectApiVersions(): void + public function testCanDetectProductionApiVersions(): void { - $versions = Generator::resolveVersions(); + $generator = new Generator(ApiDocumentationSource::Production); - static::assertNotEmpty($versions); + static::assertSame([ + '1.0' => 'https://api.logic4server.nl/openapi/v1.json', + '3.0' => 'https://api.logic4server.nl/openapi/v3.json', + '3.1' => 'https://api.logic4server.nl/openapi/v3.1.json', + ], $generator->resolveVersionsFromScalar($this->scalarFixture())); + } + + public function testCanDetectQaApiVersions(): void + { + $generator = new Generator(ApiDocumentationSource::Qa); + + static::assertSame([ + '1.0' => 'https://qa.api.logic4server.nl/openapi/v1.json', + '3.0' => 'https://qa.api.logic4server.nl/openapi/v3.json', + '3.1' => 'https://qa.api.logic4server.nl/openapi/v3.1.json', + ], $generator->resolveVersionsFromScalar($this->scalarFixture())); + } + + public function testRejectsApiDocumentationOutsideTheSelectedEnvironment(): void + { + $generator = new Generator(ApiDocumentationSource::Qa); + + $this->expectExceptionMessage('does not match the expected pattern'); + + $generator->resolveVersionsFromScalar(str_replace( + 'openapi/v3.json', + 'https://example.com/openapi/v3.json', + $this->scalarFixture(), + )); + } + + private function scalarFixture(): string + { + $contents = file_get_contents(__DIR__.'/../Fixtures/scalar.html'); + + static::assertIsString($contents); - foreach ($versions as $version => $url) { - static::assertMatchesRegularExpression('/^\d+\.\d+$/', $version); - static::assertStringStartsWith('https://api.logic4server.nl/openapi/', $url); - } + return $contents; } } diff --git a/tests/Fixtures/scalar.html b/tests/Fixtures/scalar.html new file mode 100644 index 0000000..d8af42c --- /dev/null +++ b/tests/Fixtures/scalar.html @@ -0,0 +1,12 @@ + + + + + +