diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b2979f13..5f1f93ecd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,9 +1,9 @@ -name: Tests +name: v3 Tests on: pull_request: - branches: [ "main", "v3" ] - types: [ opened, synchronize, reopened ] + branches: [ "v3" ] + types: [ opened, synchronize, reopened, labeled, unlabeled ] paths: - 'src/**' - 'config/**' @@ -15,6 +15,10 @@ on: permissions: read-all +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -103,114 +107,167 @@ jobs: run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: "Run PHPUnit Tests" - run: SPC_LIBC=glibc vendor/bin/phpunit tests/ --no-coverage + run: vendor/bin/phpunit tests/ --no-coverage - define-matrix: - if: false # TODO: enable when refactoring workflows - name: "Define Matrix" + check-gate: + name: "Check: need-test label" runs-on: ubuntu-latest outputs: - php: ${{ steps.gendef.outputs.php }} - os: ${{ steps.gendef.outputs.os }} + enabled: ${{ steps.gate.outputs.enabled }} steps: - - name: "Checkout" - uses: actions/checkout@v4 + - name: Check label + id: gate + run: | + LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}' + if echo "$LABELS" | grep -q '"need-test"'; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + test-bot: + name: "Test Bot: analyze PR" + needs: check-gate + if: needs.check-gate.outputs.enabled == 'true' + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + outputs: + need_test: ${{ steps.bot.outputs.need_test }} + gen_matrix_args: ${{ steps.bot.outputs.gen_matrix_args }} + gen_matrix_args_tier2: ${{ steps.bot.outputs.gen_matrix_args_tier2 }} + php_versions: ${{ steps.bot.outputs.php_versions }} + tier2: ${{ steps.bot.outputs.tier2 }} + steps: + - uses: actions/checkout@v4 - - name: "Setup PHP" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.4 + php-version: '8.4' extensions: curl, openssl, mbstring + ini-values: memory_limit=-1 + tools: composer + + - name: Install dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist --no-dev - - name: Define - id: gendef + - name: Run dev:test-bot + id: bot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PHP_VERSIONS=$(php src/globals/test-extensions.php php) - OS_VERSIONS=$(php src/globals/test-extensions.php os) - echo 'php='"$PHP_VERSIONS" >> "$GITHUB_OUTPUT" - echo 'os='"$OS_VERSIONS" >> "$GITHUB_OUTPUT" + BOT_JSON=$(php -d opcache.enable_cli=0 bin/spc dev:test-bot \ + --pr=${{ github.event.pull_request.number }} \ + --repo=${{ github.repository }} 2>/dev/null) + + echo "need_test=$(echo "$BOT_JSON" | jq -r '.need_test')" >> "$GITHUB_OUTPUT" + echo "gen_matrix_args=$(echo "$BOT_JSON" | jq -r '.gen_matrix_args')" >> "$GITHUB_OUTPUT" + echo "gen_matrix_args_tier2=$(echo "$BOT_JSON" | jq -r '.gen_matrix_args_tier2')" >> "$GITHUB_OUTPUT" + echo "php_versions=$(echo "$BOT_JSON" | jq -c '.php_versions')" >> "$GITHUB_OUTPUT" + echo "tier2=$(echo "$BOT_JSON" | jq -r '.tier2')" >> "$GITHUB_OUTPUT" + + COMMENT_BODY=$(echo "$BOT_JSON" | jq -r '.comment_body') + MARKER="" + + # Find existing bot comment id + EXISTING_ID=$(gh api \ + repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq "[.[] | select(.body | startswith(\"$MARKER\")) | .id] | first // empty") + + if [ -n "$EXISTING_ID" ]; then + gh api --method PATCH \ + repos/${{ github.repository }}/issues/comments/"$EXISTING_ID" \ + -f body="$COMMENT_BODY" + else + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body "$COMMENT_BODY" + fi + + gen-matrix: + name: "Generate test matrix" + needs: test-bot + if: needs.test-bot.outputs.need_test == 'true' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.build.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: curl, openssl, mbstring + ini-values: memory_limit=-1 + tools: composer - build: - if: false - name: "Build PHP Test (PHP ${{ matrix.php }} ${{ matrix.os }})" - runs-on: ${{ matrix.os }} - needs: [define-matrix, php-cs-fixer, phpstan, phpunit] + - name: Install dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist --no-dev + + - name: Build matrix + id: build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GEN_MATRIX_ARGS: ${{ needs.test-bot.outputs.gen_matrix_args }} + GEN_MATRIX_ARGS_TIER2: ${{ needs.test-bot.outputs.gen_matrix_args_tier2 }} + PHP_VERSIONS: ${{ needs.test-bot.outputs.php_versions }} + TIER2: ${{ needs.test-bot.outputs.tier2 }} + run: | + # Tier1 matrix + MATRIX1=$(bin/spc dev:gen-ext-test-matrix $GEN_MATRIX_ARGS 2>/dev/null) + + # Merge Tier2 if requested + if [ "$TIER2" = "true" ] && [ -n "$GEN_MATRIX_ARGS_TIER2" ]; then + MATRIX2=$(bin/spc dev:gen-ext-test-matrix $GEN_MATRIX_ARGS_TIER2 2>/dev/null) + COMBINED=$(jq -n --argjson m1 "$MATRIX1" --argjson m2 "$MATRIX2" '$m1 + $m2') + else + COMBINED=$MATRIX1 + fi + + # Expand PHP versions: cartesian product of entries × php_versions + FINAL=$(echo "$COMBINED" | jq --argjson versions "$PHP_VERSIONS" \ + '[.[] | . as $entry | $versions[] | $entry + {"php-version": .}]') + + echo "matrix=$(echo "$FINAL" | jq -c '{"combo": .}')" >> "$GITHUB_OUTPUT" + + ext-test: + name: "Ext test: ${{ matrix.combo.extension }} (PHP ${{ matrix.combo.php-version }} · ${{ matrix.combo.os }}-${{ matrix.combo.arch }})" + needs: gen-matrix + runs-on: ${{ matrix.combo.runner }} timeout-minutes: 120 strategy: - matrix: - php: ${{ fromJSON(needs.define-matrix.outputs.php) }} - os: ${{ fromJSON(needs.define-matrix.outputs.os) }} fail-fast: false + matrix: ${{ fromJSON(needs.gen-matrix.outputs.matrix) }} steps: - - name: "Update runner packages" - if: ${{ startsWith(matrix.os, 'ubuntu-') }} - run: sudo apt-get update && sudo apt-get install -y ca-certificates - - - name: "Checkout" - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: "Setup PHP" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.4 - tools: pecl, composer extensions: curl, openssl, mbstring ini-values: memory_limit=-1 + tools: composer env: phpts: nts - - name: "Cache composer packages" - id: composer-cache - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php- - - # Cache downloaded source - - id: cache-download - uses: actions/cache@v4 - with: - path: downloads - key: php-dependencies-${{ matrix.os }} + - name: Install dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist --no-dev - - name: "Install Dependencies" - run: composer update -vvv --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist --no-plugins - - - name: "Run Build Tests (doctor)" - run: php src/globals/test-extensions.php doctor_cmd ${{ matrix.os }} ${{ matrix.php }} - - - name: "Prepare UPX for Windows" - if: ${{ startsWith(matrix.os, 'windows-') }} - run: | - php src/globals/test-extensions.php install_upx_cmd ${{ matrix.os }} ${{ matrix.php }} - echo "UPX_CMD=$(php src/globals/test-extensions.php upx)" >> $env:GITHUB_ENV - - - name: "Prepare UPX for Linux" - if: ${{ startsWith(matrix.os, 'ubuntu-') }} + - name: Build + env: + SPC_USE_SUDO: "yes" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - php src/globals/test-extensions.php install_upx_cmd ${{ matrix.os }} ${{ matrix.php }} - echo "UPX_CMD=$(php src/globals/test-extensions.php upx)" >> $GITHUB_ENV - - - name: "Run Build Tests (download)" - run: php src/globals/test-extensions.php download_cmd ${{ matrix.os }} ${{ matrix.php }} + ./bin/spc doctor --auto-fix + ${{ matrix.combo.build-args }} --dl-with-php=${{ matrix.combo.php-version }} - - name: "Run Build Tests (build)" - run: php src/globals/test-extensions.php build_cmd ${{ matrix.os }} ${{ matrix.php }} - - - name: "Run Build Tests (build - embed for non-windows)" - if: ${{ !startsWith(matrix.os, 'windows-') }} - run: php src/globals/test-extensions.php build_embed_cmd ${{ matrix.os }} ${{ matrix.php }} - - - name: "Upload logs" - if: ${{ always() && hashFiles('log/**') != '' }} - uses: actions/upload-artifact@v7 + - name: Upload logs + if: always() && hashFiles('log/**') != '' + uses: actions/upload-artifact@v4 with: - name: build-logs-${{ matrix.os }}-${{ matrix.php }} + name: logs-${{ matrix.combo.os }}-${{ matrix.combo.arch }}-${{ matrix.combo.extension }}-php${{ matrix.combo.php-version }} path: log - -# - name: Setup tmate session -# if: ${{ failure() }} -# uses: mxschmitt/action-tmate@v3 diff --git a/config/artifact/ncurses.yml b/config/artifact/ncurses.yml index 52c8f59ff..173d4c326 100644 --- a/config/artifact/ncurses.yml +++ b/config/artifact/ncurses.yml @@ -1,5 +1,4 @@ ncurses: - binary: hosted metadata: license-files: - COPYING diff --git a/config/pkg/ext/builtin-extensions.yml b/config/pkg/ext/builtin-extensions.yml index 52478e2c3..750745cba 100644 --- a/config/pkg/ext/builtin-extensions.yml +++ b/config/pkg/ext/builtin-extensions.yml @@ -348,6 +348,7 @@ ext-xmlreader: type: php-extension depends: - ext-xml + - ext-dom php-extension: arg-type: enable build-with-php: true diff --git a/config/pkg/ext/ext-ds.yml b/config/pkg/ext/ext-ds.yml index 0c0a4b3c4..779582574 100644 --- a/config/pkg/ext/ext-ds.yml +++ b/config/pkg/ext/ext-ds.yml @@ -2,8 +2,10 @@ ext-ds: type: php-extension artifact: source: - type: pecl - name: ds + type: git + url: 'https://github.com/php-ds/ext-ds.git' + rev: master + extract: php-src/ext/ds metadata: license-files: [LICENSE] license: MIT diff --git a/config/pkg/ext/ext-swow.yml b/config/pkg/ext/ext-swow.yml index 11592cd0b..eb25bc815 100644 --- a/config/pkg/ext/ext-swow.yml +++ b/config/pkg/ext/ext-swow.yml @@ -9,10 +9,5 @@ ext-swow: metadata: license: Apache-2.0 license-files: [LICENSE] - suggests: - - openssl - - curl - - ext-openssl - - ext-curl php-extension: arg-type: custom diff --git a/config/pkg/lib/brotli.yml b/config/pkg/lib/brotli.yml index c88b93b5a..19c7981d3 100644 --- a/config/pkg/lib/brotli.yml +++ b/config/pkg/lib/brotli.yml @@ -5,7 +5,6 @@ brotli: type: ghtagtar repo: google/brotli match: 'v1\.\d.*' - binary: hosted metadata: license-files: [LICENSE] license: MIT diff --git a/config/pkg/lib/bzip2.yml b/config/pkg/lib/bzip2.yml index f9e1870d8..e4e98363a 100644 --- a/config/pkg/lib/bzip2.yml +++ b/config/pkg/lib/bzip2.yml @@ -8,7 +8,6 @@ bzip2: type: filelist url: 'https://sourceware.org/pub/bzip2/' regex: '/href="(?bzip2-(?[^"]+)\.tar\.gz)"/' - binary: hosted metadata: license-files: ['@/bzip2.txt'] license: bzip2-1.0.6 diff --git a/config/pkg/lib/glfw.yml b/config/pkg/lib/glfw.yml index f7d015492..7bd46e4a8 100644 --- a/config/pkg/lib/glfw.yml +++ b/config/pkg/lib/glfw.yml @@ -1,6 +1,12 @@ glfw: type: library artifact: glfw + frameworks: + - Cocoa + - CoreFoundation + - CoreVideo + - IOKit + - QuartzCore headers: - GLFW/glfw3.h - GLFW/glfw3native.h diff --git a/config/pkg/lib/libcares.yml b/config/pkg/lib/libcares.yml index 5d3f8c9d1..a2eb9e80b 100644 --- a/config/pkg/lib/libcares.yml +++ b/config/pkg/lib/libcares.yml @@ -10,7 +10,6 @@ libcares: type: filelist url: 'https://c-ares.org/download/' regex: '/href="\/download\/(?c-ares-(?[^"]+)\.tar\.gz)"/' - binary: hosted metadata: license-files: [LICENSE.md] headers@unix: diff --git a/config/pkg/lib/libedit.yml b/config/pkg/lib/libedit.yml index 02d6dd810..0f1ed78b7 100644 --- a/config/pkg/lib/libedit.yml +++ b/config/pkg/lib/libedit.yml @@ -5,7 +5,6 @@ libedit: type: filelist url: 'https://thrysoee.dk/editline/' regex: '/href="(?libedit-(?[^"]+)\.tar\.gz)"/' - binary: hosted metadata: license-files: [COPYING] license: BSD-3-Clause diff --git a/config/pkg/lib/libiconv.yml b/config/pkg/lib/libiconv.yml index 7fc3bddad..58cd77b08 100644 --- a/config/pkg/lib/libiconv.yml +++ b/config/pkg/lib/libiconv.yml @@ -5,7 +5,6 @@ libiconv: type: filelist url: 'https://ftp.gnu.org/gnu/libiconv/' regex: '/href="(?libiconv-(?[^"]+)\.tar\.gz)"/' - binary: hosted metadata: license-files: [COPYING.LIB] license: LGPL-2.0-or-later diff --git a/config/pkg/lib/libpng.yml b/config/pkg/lib/libpng.yml index 083cf430a..a58bdfa5f 100644 --- a/config/pkg/lib/libpng.yml +++ b/config/pkg/lib/libpng.yml @@ -6,7 +6,6 @@ libpng: repo: pnggroup/libpng match: v1\.6\.\d+ query: '?per_page=150' - binary: hosted metadata: license-files: [LICENSE] license: PNG diff --git a/config/pkg/lib/libsodium.yml b/config/pkg/lib/libsodium.yml index 4bd41363b..9497381ba 100644 --- a/config/pkg/lib/libsodium.yml +++ b/config/pkg/lib/libsodium.yml @@ -6,7 +6,6 @@ libsodium: repo: jedisct1/libsodium match: 'libsodium-(?!1\.0\.21)\d+(\.\d+)*\.tar\.gz' prefer-stable: true - binary: hosted metadata: license-files: [LICENSE] pkg-configs: diff --git a/config/pkg/lib/libssh2.yml b/config/pkg/lib/libssh2.yml index 2916e3a9c..724474162 100644 --- a/config/pkg/lib/libssh2.yml +++ b/config/pkg/lib/libssh2.yml @@ -6,7 +6,6 @@ libssh2: repo: libssh2/libssh2 match: libssh2.+\.tar\.gz prefer-stable: true - binary: hosted metadata: license-files: [COPYING] license: BSD-3-Clause diff --git a/config/pkg/lib/libunistring.yml b/config/pkg/lib/libunistring.yml index 2b2ffd334..13a66d42e 100644 --- a/config/pkg/lib/libunistring.yml +++ b/config/pkg/lib/libunistring.yml @@ -5,7 +5,6 @@ libunistring: type: filelist url: 'https://ftp.gnu.org/gnu/libunistring/' regex: '/href="(?libunistring-(?[^"]+)\.tar\.gz)"/' - binary: hosted metadata: license-files: [COPYING.LIB] license: LGPL-3.0-or-later diff --git a/config/pkg/lib/openssl.yml b/config/pkg/lib/openssl.yml index f526725b6..cbbd0984c 100644 --- a/config/pkg/lib/openssl.yml +++ b/config/pkg/lib/openssl.yml @@ -10,7 +10,6 @@ openssl: type: filelist url: 'https://www.openssl.org/source/' regex: '/href="(?openssl-(?3\.[^"]+)\.tar\.gz)"/' - binary: hosted metadata: license-files: [LICENSE.txt] license: OpenSSL diff --git a/config/pkg/lib/xz.yml b/config/pkg/lib/xz.yml index 3be1815d8..04b931840 100644 --- a/config/pkg/lib/xz.yml +++ b/config/pkg/lib/xz.yml @@ -6,7 +6,6 @@ xz: repo: tukaani-project/xz match: xz.+\.tar\.xz prefer-stable: true - binary: hosted metadata: license-files: [COPYING] license: 0BSD diff --git a/config/pkg/lib/zlib.yml b/config/pkg/lib/zlib.yml index b4e71364e..3ab591805 100644 --- a/config/pkg/lib/zlib.yml +++ b/config/pkg/lib/zlib.yml @@ -5,7 +5,6 @@ zlib: type: ghrel repo: madler/zlib match: zlib.+\.tar\.gz - binary: hosted metadata: license-files: ['@/zlib.txt'] license: Zlib-Custom diff --git a/config/pkg/lib/zstd.yml b/config/pkg/lib/zstd.yml index c1d15cf6e..be0061617 100644 --- a/config/pkg/lib/zstd.yml +++ b/config/pkg/lib/zstd.yml @@ -19,3 +19,4 @@ zstd: - libzstd.a static-libs@windows: - zstd.lib + - libzstd.lib diff --git a/config/pkg/target/curl.yml b/config/pkg/target/curl.yml index 78064510c..8140bf728 100644 --- a/config/pkg/target/curl.yml +++ b/config/pkg/target/curl.yml @@ -16,6 +16,8 @@ curl: - zlib - libssh2 - nghttp2 + - brotli + - zstd suggests@unix: - libssh2 - brotli @@ -27,9 +29,6 @@ curl: - ldap - idn2 - krb5 - suggests@windows: - - brotli - - zstd frameworks: - CoreFoundation - CoreServices diff --git a/docs/en/guide/cli-reference.md b/docs/en/guide/cli-reference.md index a02c6ec53..5a162f97a 100644 --- a/docs/en/guide/cli-reference.md +++ b/docs/en/guide/cli-reference.md @@ -36,7 +36,7 @@ spc download [artifacts] [options] | `--binary-only` | | Only download binary artifacts | | `--parallel=` | `-P` | Number of parallel downloads (default: `1`) | | `--retry=` | `-R` | Number of retries on failure (default: `0`) | -| `--ignore-cache=` | | Force re-download the specified artifacts | +| `--ignore-cache=` | `-i` | Force re-download the specified artifacts | | `--no-alt` | | Do not use alternative mirror URLs | | `--no-shallow-clone` | | Do not clone git repositories shallowly | | `--custom-url=` | `-U` | Override the download URL for a source | diff --git a/docs/zh/guide/cli-reference.md b/docs/zh/guide/cli-reference.md index 961e0affa..7c0d5d33a 100644 --- a/docs/zh/guide/cli-reference.md +++ b/docs/zh/guide/cli-reference.md @@ -22,23 +22,23 @@ spc download [artifacts] [options] ### 选项 -| 选项 | 缩写 | 说明 | -|---|---|---| +| 选项 | 缩写 | 说明 | +|---|------|---| | `--for-extensions=` | `-e` | 按扩展名下载其所需的制品 | | `--for-libs=` | `-l` | 按库名下载其所需的制品 | -| `--for-packages=` | | 按包名下载其所需的制品 | -| `--without-suggests` | | 使用 `--for-extensions` 时跳过建议包 | -| `--clean` | | 下载前删除旧的下载缓存 | -| `--with-php=` | | PHP 版本,格式为 `major.minor`(默认 `8.4`)| +| `--for-packages=` | | 按包名下载其所需的制品 | +| `--without-suggests` | | 使用 `--for-extensions` 时跳过建议包 | +| `--clean` | | 下载前删除旧的下载缓存 | +| `--with-php=` | | PHP 版本,格式为 `major.minor`(默认 `8.4`)| | `--prefer-binary` | `-p` | 优先使用预编译二进制 | -| `--prefer-source` | | 优先使用源码包 | -| `--source-only` | | 仅下载源码制品 | -| `--binary-only` | | 仅下载二进制制品 | +| `--prefer-source` | | 优先使用源码包 | +| `--source-only` | | 仅下载源码制品 | +| `--binary-only` | | 仅下载二进制制品 | | `--parallel=` | `-P` | 并行下载数(默认 `1`)| | `--retry=` | `-R` | 失败重试次数(默认 `0`)| -| `--ignore-cache=` | | 强制重新下载指定制品 | -| `--no-alt` | | 不使用镜像站 | -| `--no-shallow-clone` | | 不使用浅层克隆 | +| `--ignore-cache=` | `-i` | 强制重新下载指定制品 | +| `--no-alt` | | 不使用镜像站 | +| `--no-shallow-clone` | | 不使用浅层克隆 | | `--custom-url=` | `-U` | 覆盖指定源的下载地址 | | `--custom-git=` | `-G` | 覆盖为自定义 git 仓库 | | `--custom-local=` | `-L` | 使用本地路径作为制品来源 | diff --git a/src/Package/Artifact/go_win.php b/src/Package/Artifact/go_win.php index 44cf61b96..e06e615ab 100644 --- a/src/Package/Artifact/go_win.php +++ b/src/Package/Artifact/go_win.php @@ -23,13 +23,13 @@ public function downBinary(ArtifactDownloader $downloader): DownloadResult $pkgroot = PKG_ROOT_PATH; // get version - [$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text') ?: ''); + [$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text', retries: $downloader->getRetry()) ?: ''); if ($version === '') { throw new DownloaderException('Failed to get latest Go version from https://go.dev/VERSION?m=text'); } // find SHA256 hash from download page - $page = default_shell()->executeCurl('https://go.dev/dl/'); + $page = default_shell()->executeCurl('https://go.dev/dl/', retries: $downloader->getRetry()); if ($page === '' || $page === false) { throw new DownloaderException('Failed to get Go download page from https://go.dev/dl/'); } diff --git a/src/Package/Artifact/go_xcaddy.php b/src/Package/Artifact/go_xcaddy.php index ca61bd465..51ccfb87b 100644 --- a/src/Package/Artifact/go_xcaddy.php +++ b/src/Package/Artifact/go_xcaddy.php @@ -39,11 +39,11 @@ public function downBinary(ArtifactDownloader $downloader): DownloadResult }; // get version and hash - [$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text') ?: ''); + [$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text', retries: $downloader->getRetry()) ?: ''); if ($version === '') { throw new DownloaderException('Failed to get latest Go version from https://go.dev/VERSION?m=text'); } - $page = default_shell()->executeCurl('https://go.dev/dl/'); + $page = default_shell()->executeCurl('https://go.dev/dl/', retries: $downloader->getRetry()); if ($page === '' || $page === false) { throw new DownloaderException('Failed to get Go download page from https://go.dev/dl/'); } diff --git a/src/Package/Extension/imap.php b/src/Package/Extension/imap.php index e9879b48b..df53cfe70 100644 --- a/src/Package/Extension/imap.php +++ b/src/Package/Extension/imap.php @@ -9,6 +9,7 @@ use StaticPHP\Attribute\Package\CustomPhpConfigureArg; use StaticPHP\Attribute\Package\Extension; use StaticPHP\Attribute\Package\Validate; +use StaticPHP\Attribute\PatchDescription; use StaticPHP\Exception\WrongUsageException; use StaticPHP\Package\PackageBuilder; use StaticPHP\Package\PackageInstaller; @@ -26,6 +27,19 @@ public function validate(PackageBuilder $builder): void } } + #[BeforeStage('php', [php::class, 'makeCliForUnix'], 'ext-imap')] + #[PatchDescription('Fix imap zend_zval_value_name() call for PHP 8.2 compatibility')] + public function patchBeforeMake(): void + { + // zend_zval_value_name() was introduced in PHP 8.3; PHP 8.2 imap backported the call but not the declaration + // replace with the equivalent PHP 8.2-compatible function + FileSystem::replaceFileStr( + "{$this->getSourceDir()}/php_imap.c", + 'zend_zval_value_name(data)', + 'zend_zval_type_name(data)' + ); + } + #[BeforeStage('php', [php::class, 'buildconfForUnix'], 'ext-imap')] public function patchBeforeBuildconf(PackageInstaller $installer): void { diff --git a/src/Package/Extension/intl.php b/src/Package/Extension/intl.php index f5e17c1fe..dd7d3ffd9 100644 --- a/src/Package/Extension/intl.php +++ b/src/Package/Extension/intl.php @@ -16,7 +16,7 @@ class intl extends PhpExtensionPackage { #[BeforeStage('php', [php::class, 'buildconfForWindows'], 'ext-intl')] - #[PatchDescription('Fix intl config.w32: replace hardcoded true with PHP_INTL_SHARED for static build support')] + #[PatchDescription('Fix intl config.w32: replace hardcoded true with PHP_INTL_SHARED for static build support; add /std:c++17 required by ICU 73+')] public function patchBeforeBuildconfForWindows(PackageInstaller $installer): void { $php_src = $installer->getTargetPackage('php')->getSourceDir(); @@ -25,5 +25,11 @@ public function patchBeforeBuildconfForWindows(PackageInstaller $installer): voi 'EXTENSION("intl", "php_intl.c intl_convert.c intl_convertcpp.cpp intl_error.c ", true,', 'EXTENSION("intl", "php_intl.c intl_convert.c intl_convertcpp.cpp intl_error.c ", PHP_INTL_SHARED,' ); + // ICU 73+ headers (char16ptr.h etc.) unconditionally include which requires C++17. + FileSystem::replaceFileStr( + "{$php_src}/ext/intl/config.w32", + 'ADD_FLAG("CFLAGS_INTL", "/EHsc', + 'ADD_FLAG("CFLAGS_INTL", "/std:c++17 /EHsc' + ); } } diff --git a/src/Package/Extension/memcache.php b/src/Package/Extension/memcache.php index a9c58b768..3fac40228 100644 --- a/src/Package/Extension/memcache.php +++ b/src/Package/Extension/memcache.php @@ -17,6 +17,20 @@ class memcache extends PhpExtensionPackage #[BeforeStage('php', [php::class, 'buildconfForUnix'], 'ext-memcache')] public function patchBeforeBuildconf(): bool { + // PHP 8.5 moved php_smart_string*.h from ext/standard/ to Zend/ + foreach (['src/memcache_pool.h', 'src/memcache_pool.c', 'src/memcache_session.c', 'src/memcache_ascii_protocol.c', 'src/memcache_binary_protocol.c'] as $file) { + FileSystem::replaceFileStr( + "{$this->getSourceDir()}/{$file}", + '#include "ext/standard/php_smart_string_public.h"', + '#include "Zend/zend_smart_string_public.h"', + ); + FileSystem::replaceFileStr( + "{$this->getSourceDir()}/{$file}", + '#include "ext/standard/php_smart_string.h"', + '#include "Zend/zend_smart_string.h"', + ); + } + if (!$this->isBuildStatic()) { return false; } diff --git a/src/Package/Extension/opentelemetry.php b/src/Package/Extension/opentelemetry.php index 632d12571..6c106f7f5 100644 --- a/src/Package/Extension/opentelemetry.php +++ b/src/Package/Extension/opentelemetry.php @@ -7,15 +7,21 @@ use Package\Target\php; use StaticPHP\Attribute\Package\BeforeStage; use StaticPHP\Attribute\Package\Extension; +use StaticPHP\Toolchain\Interface\ToolchainInterface; +use StaticPHP\Toolchain\ZigToolchain; use StaticPHP\Util\GlobalEnvManager; #[Extension('opentelemetry')] class opentelemetry { #[BeforeStage('php', [php::class, 'makeForUnix'], 'ext-opentelemetry')] - public function patchBeforeMake(): void + public function patchBeforeMake(ToolchainInterface $toolchain): void { - // add -Wno-strict-prototypes - GlobalEnvManager::putenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS=' . getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS') . ' -Wno-strict-prototypes'); + $extra_cflags = getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS') ?: ''; + $extra_cflags .= ' -Wno-strict-prototypes'; + if ($toolchain instanceof ZigToolchain) { + $extra_cflags .= ' -Wno-unknown-warning-option'; + } + GlobalEnvManager::putenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS=' . trim($extra_cflags)); } } diff --git a/src/Package/Extension/swow.php b/src/Package/Extension/swow.php index 3ebe3d582..c49bc191b 100644 --- a/src/Package/Extension/swow.php +++ b/src/Package/Extension/swow.php @@ -20,7 +20,7 @@ class swow extends PhpExtensionPackage #[CustomPhpConfigureArg('Windows')] public function configureArg(PackageInstaller $installer): string { - $arg = '--enable-swow'; + $arg = '--enable-swow --disable-swow-pdo-pgsql'; $arg .= $installer->getLibraryPackage('openssl') ? ' --enable-swow-ssl' : ' --disable-swow-ssl'; $arg .= $installer->getLibraryPackage('curl') ? ' --enable-swow-curl' : ' --disable-swow-curl'; return $arg; diff --git a/src/Package/Extension/zlib.php b/src/Package/Extension/zlib.php index 14ab656d7..d04c00a03 100644 --- a/src/Package/Extension/zlib.php +++ b/src/Package/Extension/zlib.php @@ -8,15 +8,16 @@ use StaticPHP\Attribute\Package\CustomPhpConfigureArg; use StaticPHP\Attribute\Package\Extension; use StaticPHP\Package\PackageBuilder; +use StaticPHP\Package\PackageInstaller; #[Extension('zlib')] class zlib { #[CustomPhpConfigureArg('Darwin')] #[CustomPhpConfigureArg('Linux')] - public function unixConfigureArg(PackageBuilder $builder): string + public function unixConfigureArg(PackageBuilder $builder, PackageInstaller $installer): string { - $zlib_dir = php::getPHPVersionID() >= 80400 ? '' : ' --with-zlib-dir=' . $builder->getBuildRootPath(); - return '--with-zlib' . $zlib_dir; + $zlib_dir = (php::getPHPVersionID() >= 80400 && !$installer->getPhpExtensionPackage('spx')) ? '' : " --with-zlib-dir={$builder->getBuildRootPath()}"; + return "--with-zlib{$zlib_dir}"; } } diff --git a/src/Package/Library/mpir.php b/src/Package/Library/mpir.php index 951d218ad..8063b7826 100644 --- a/src/Package/Library/mpir.php +++ b/src/Package/Library/mpir.php @@ -33,7 +33,7 @@ public function build(LibraryPackage $lib): void { $vs_ver_dir = ApplicationContext::get('mpir_vs_ver_dir'); cmd()->cd("{$lib->getSourceDir()}{$vs_ver_dir}\\lib_mpir_gc") - ->exec('msbuild lib_mpir_gc.vcxproj /t:Rebuild /p:Configuration=Release /p:Platform=x64'); + ->exec('msbuild lib_mpir_gc.vcxproj /t:Rebuild /p:Configuration=Release /p:Platform=x64 /p:WindowsTargetPlatformVersion=10.0'); FileSystem::createDir($lib->getLibDir()); FileSystem::createDir($lib->getIncludeDir()); FileSystem::copy("{$lib->getSourceDir()}{$vs_ver_dir}\\lib_mpir_gc\\x64\\Release\\mpir_a.lib", "{$lib->getLibDir()}\\mpir_a.lib"); diff --git a/src/Package/Library/zstd.php b/src/Package/Library/zstd.php index 4b4a490f1..a5df737e5 100644 --- a/src/Package/Library/zstd.php +++ b/src/Package/Library/zstd.php @@ -26,6 +26,7 @@ public function buildWin(LibraryPackage $package): void ) ->build(); FileSystem::copy($package->getLibDir() . '\zstd_static.lib', $package->getLibDir() . '/zstd.lib'); + FileSystem::copy($package->getLibDir() . '\zstd_static.lib', $package->getLibDir() . '/libzstd.lib'); } #[BuildFor('Linux')] diff --git a/src/Package/Target/php/unix.php b/src/Package/Target/php/unix.php index e1052fbdc..bd2871148 100644 --- a/src/Package/Target/php/unix.php +++ b/src/Package/Target/php/unix.php @@ -266,43 +266,32 @@ public function makeFpmForUnix(TargetPackage $package, PackageInstaller $install #[PatchDescription('Patch phar extension for micro SAPI to support compressed phar')] public function makeMicroForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void { - $phar_patched = false; - try { - if ($installer->isPackageResolved('ext-phar')) { - $phar_patched = true; - SourcePatcher::patchMicroPhar(self::getPHPVersionID()); - } - InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make micro')); - // apply --with-micro-fake-cli option - $vars = $this->makeVars($installer); - $vars['EXTRA_CFLAGS'] .= $package->getBuildOption('with-micro-fake-cli', false) ? ' -DPHP_MICRO_FAKE_CLI' : ''; - $makeArgs = $this->makeVarsToArgs($vars); - // build - shell()->cd($package->getSourceDir()) - ->setEnv($vars) - ->exec("make -j{$builder->concurrency} {$makeArgs} micro"); - - $dst = BUILD_BIN_PATH . '/micro.sfx'; - $builder->deployBinary($package->getSourceDir() . '/sapi/micro/micro.sfx', $dst); - // patch after UPX-ed micro.sfx (Linux only) - if (SystemTarget::getTargetOS() === 'Linux' && $builder->getOption('with-upx-pack')) { - // cut binary with readelf to remove UPX extra segment - [$ret, $out] = shell()->execWithResult("readelf -l {$dst} | awk '/LOAD|GNU_STACK/ {getline; print \\$1, \\$2, \\$3, \\$4, \\$6, \\$7}'"); - $out[1] = explode(' ', $out[1]); - $offset = $out[1][0]; - if ($ret !== 0 || !str_starts_with($offset, '0x')) { - throw new PatchException('phpmicro UPX patcher', 'Cannot find offset in readelf output'); - } - $offset = hexdec($offset); - // remove upx extra wastes - file_put_contents($dst, substr(file_get_contents($dst), 0, $offset)); - } - $package->setOutput('Binary path for micro SAPI', $dst); - } finally { - if ($phar_patched) { - SourcePatcher::unpatchMicroPhar(); + InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make micro')); + // apply --with-micro-fake-cli option + $vars = $this->makeVars($installer); + $vars['EXTRA_CFLAGS'] .= $package->getBuildOption('with-micro-fake-cli', false) ? ' -DPHP_MICRO_FAKE_CLI' : ''; + $makeArgs = $this->makeVarsToArgs($vars); + // build + shell()->cd($package->getSourceDir()) + ->setEnv($vars) + ->exec("make -j{$builder->concurrency} {$makeArgs} micro"); + + $dst = BUILD_BIN_PATH . '/micro.sfx'; + $builder->deployBinary($package->getSourceDir() . '/sapi/micro/micro.sfx', $dst); + // patch after UPX-ed micro.sfx (Linux only) + if (SystemTarget::getTargetOS() === 'Linux' && $builder->getOption('with-upx-pack')) { + // cut binary with readelf to remove UPX extra segment + [$ret, $out] = shell()->execWithResult("readelf -l {$dst} | awk '/LOAD|GNU_STACK/ {getline; print \\$1, \\$2, \\$3, \\$4, \\$6, \\$7}'"); + $out[1] = explode(' ', $out[1]); + $offset = $out[1][0]; + if ($ret !== 0 || !str_starts_with($offset, '0x')) { + throw new PatchException('phpmicro UPX patcher', 'Cannot find offset in readelf output'); } + $offset = hexdec($offset); + // remove upx extra wastes + file_put_contents($dst, substr(file_get_contents($dst), 0, $offset)); } + $package->setOutput('Binary path for micro SAPI', $dst); } #[Stage] diff --git a/src/Package/Target/php/windows.php b/src/Package/Target/php/windows.php index 311db0333..d2f863327 100644 --- a/src/Package/Target/php/windows.php +++ b/src/Package/Target/php/windows.php @@ -293,21 +293,8 @@ public function makeMicroForWindows(TargetPackage $package, PackageBuilder $buil $fake_cli = $package->getBuildOption('with-micro-fake-cli', false) ? ' /DPHP_MICRO_FAKE_CLI' : ''; - // phar patch for micro - $phar_patched = false; - if ($installer->isPackageResolved('ext-phar')) { - $phar_patched = true; - SourcePatcher::patchMicroPhar(self::getPHPVersionID()); - } - - try { - cmd()->cd($package->getSourceDir()) - ->exec("nmake /nologo {$debug_overrides}LIBS_MICRO=\"ws2_32.lib shell32.lib {$extra_libs}\" CFLAGS_MICRO=\"/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1{$fake_cli}\" EXTRA_LD_FLAGS_PROGRAM= micro"); - } finally { - if ($phar_patched) { - SourcePatcher::unpatchMicroPhar(); - } - } + cmd()->cd($package->getSourceDir()) + ->exec("nmake /nologo {$debug_overrides}LIBS_MICRO=\"ws2_32.lib shell32.lib {$extra_libs}\" CFLAGS_MICRO=\"/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1{$fake_cli}\" EXTRA_LD_FLAGS_PROGRAM= micro"); $this->deployWindowsBinary($builder, $package, 'php-micro'); } diff --git a/src/StaticPHP/Artifact/ArtifactExtractor.php b/src/StaticPHP/Artifact/ArtifactExtractor.php index 8b73243a4..987ec554e 100644 --- a/src/StaticPHP/Artifact/ArtifactExtractor.php +++ b/src/StaticPHP/Artifact/ArtifactExtractor.php @@ -242,7 +242,10 @@ protected function extractBinary(Artifact $artifact): int } logger()->info("Extracting binary [{$name}] to {$target_path}..."); - $this->doStandardExtract($name, $cache_info, $target_path); + // When a binary artifact targets the shared buildroot, merge into it instead of wiping it. + // Wiping buildroot would destroy files installed by packages processed earlier in the build queue. + $merge = (FileSystem::convertPath($target_path) === FileSystem::convertPath(BUILD_ROOT_PATH)); + $this->doStandardExtract($name, $cache_info, $target_path, $merge); $artifact->emitAfterBinaryExtract($target_path, $platform); logger()->debug("Emitted after-binary-extract hooks for [{$name}]"); @@ -256,8 +259,10 @@ protected function extractBinary(Artifact $artifact): int /** * Standard extraction: extract entire archive to target directory. + * + * @param bool $merge when true, merge extracted files into existing target dir instead of wiping it */ - protected function doStandardExtract(string $name, array $cache_info, string $target_path): void + protected function doStandardExtract(string $name, array $cache_info, string $target_path, bool $merge = false): void { $source_file = $this->cache->getCacheFullPath($cache_info); $cache_type = $cache_info['cache_type']; @@ -265,7 +270,7 @@ protected function doStandardExtract(string $name, array $cache_info, string $ta // Validate source file exists before extraction $this->validateSourceFile($name, $source_file, $cache_type); - $this->extractWithType($cache_type, $source_file, $target_path); + $this->extractWithType($cache_type, $source_file, $target_path, $merge); } /** @@ -443,10 +448,10 @@ protected function copyFileOrDir(string $src, string $dst): void * @param string $source_file Path to source file or directory * @param string $target_path Target extraction path */ - protected function extractWithType(string $cache_type, string $source_file, string $target_path): void + protected function extractWithType(string $cache_type, string $source_file, string $target_path, bool $merge = false): void { match ($cache_type) { - 'archive' => $this->extractArchive($source_file, $target_path), + 'archive' => $this->extractArchive($source_file, $target_path, $merge), 'file' => $this->copyFile($source_file, $target_path), 'git' => FileSystem::copyDir(FileSystem::convertPath($source_file), $target_path), 'local' => symlink(FileSystem::convertPath($source_file), $target_path), @@ -458,8 +463,10 @@ protected function extractWithType(string $cache_type, string $source_file, stri * Extract archive file to target directory. * * Supports: tar, tar.gz, tgz, tar.bz2, tar.xz, txz, zip, exe + * + * @param bool $merge when true, merge zip contents into existing target dir instead of wiping it */ - protected function extractArchive(string $filename, string $target): void + protected function extractArchive(string $filename, string $target, bool $merge = false): void { $target = FileSystem::convertPath($target); $filename = FileSystem::convertPath($filename); @@ -476,7 +483,7 @@ protected function extractArchive(string $filename, string $target): void 'Windows' => match ($extname) { 'tar' => default_shell()->executeTarExtract($filename, $target, 'none'), 'xz', 'txz', 'gz', 'tgz', 'bz2' => default_shell()->execute7zExtract($filename, $target), - 'zip' => $this->unzipWithStrip($filename, $target), + 'zip' => $this->unzipWithStrip($filename, $target, $merge), 'exe' => $this->copyFile($filename, $target), default => throw new FileSystemException("Unknown archive format: {$filename}"), }, @@ -485,7 +492,7 @@ protected function extractArchive(string $filename, string $target): void 'gz', 'tgz' => default_shell()->executeTarExtract($filename, $target, 'gz'), 'bz2' => default_shell()->executeTarExtract($filename, $target, 'bz2'), 'xz', 'txz' => default_shell()->executeTarExtract($filename, $target, 'xz'), - 'zip' => $this->unzipWithStrip($filename, $target), + 'zip' => $this->unzipWithStrip($filename, $target, $merge), 'exe' => $this->copyFile($filename, $target), default => throw new FileSystemException("Unknown archive format: {$filename}"), }, @@ -496,7 +503,7 @@ protected function extractArchive(string $filename, string $target): void /** * Unzip file with stripping top-level directory. */ - protected function unzipWithStrip(string $zip_file, string $extract_path): bool + protected function unzipWithStrip(string $zip_file, string $extract_path, bool $merge = false): bool { $temp_dir = FileSystem::convertPath(sys_get_temp_dir() . '/spc_unzip_' . bin2hex(random_bytes(16))); $zip_file = FileSystem::convertPath($zip_file); @@ -517,15 +524,22 @@ protected function unzipWithStrip(string $zip_file, string $extract_path): bool throw new FileSystemException('Cannot scan unzip temp dir: ' . $temp_dir); } - // If extract path already exists, remove it - if (is_dir($extract_path)) { - FileSystem::removeDir($extract_path); + if (!$merge) { + // Replace mode: wipe the target directory before extracting + if (is_dir($extract_path)) { + FileSystem::removeDir($extract_path); + } } - // If only one dir, move its contents to extract_path + // If only one dir, move/merge its contents to extract_path $subdir = FileSystem::convertPath("{$temp_dir}/{$contents[0]}"); if (count($contents) === 1 && is_dir($subdir)) { - $this->moveFileOrDir($subdir, $extract_path); + if ($merge) { + $this->mergeDirContent($subdir, $extract_path); + FileSystem::removeDir($subdir); + } else { + $this->moveFileOrDir($subdir, $extract_path); + } } else { // Else, if it contains only one dir, strip dir and copy other files $dircount = 0; @@ -550,26 +564,36 @@ protected function unzipWithStrip(string $zip_file, string $extract_path): bool throw new FileSystemException("Cannot scan unzip temp sub-dir: {$dir[0]}"); } foreach ($sub_contents as $sub_item) { - $this->moveFileOrDir( - FileSystem::convertPath("{$temp_dir}/{$dir[0]}/{$sub_item}"), - FileSystem::convertPath("{$extract_path}/{$sub_item}") - ); + $src = FileSystem::convertPath("{$temp_dir}/{$dir[0]}/{$sub_item}"); + $dst = FileSystem::convertPath("{$extract_path}/{$sub_item}"); + if ($merge && is_dir($src)) { + $this->mergeDirContent($src, $dst); + } else { + $this->moveFileOrDir($src, $dst); + } } } else { foreach ($dir as $item) { - $this->moveFileOrDir( - FileSystem::convertPath("{$temp_dir}/{$item}"), - FileSystem::convertPath("{$extract_path}/{$item}") - ); + $src = FileSystem::convertPath("{$temp_dir}/{$item}"); + $dst = FileSystem::convertPath("{$extract_path}/{$item}"); + if ($merge) { + $this->mergeDirContent($src, $dst); + } else { + $this->moveFileOrDir($src, $dst); + } } } - // Move top-level files to extract_path + // Move or copy top-level files to extract_path foreach ($top_files as $top_file) { - $this->moveFileOrDir( - FileSystem::convertPath("{$temp_dir}/{$top_file}"), - FileSystem::convertPath("{$extract_path}/{$top_file}") - ); + $src = FileSystem::convertPath("{$temp_dir}/{$top_file}"); + $dst = FileSystem::convertPath("{$extract_path}/{$top_file}"); + if ($merge) { + FileSystem::createDir(dirname($dst)); + copy($src, $dst); + } else { + $this->moveFileOrDir($src, $dst); + } } } @@ -595,6 +619,25 @@ protected function replacePathVariables(string $path): string return str_replace(array_keys($replacement), array_values($replacement), $path); } + private function mergeDirContent(string $src_dir, string $dest_dir): void + { + FileSystem::createDir($dest_dir); + $items = FileSystem::scanDirFiles($src_dir, false, true, true); + if ($items === false || empty($items)) { + return; + } + foreach ($items as $item) { + $src_item = FileSystem::convertPath("{$src_dir}/{$item}"); + $dest_item = FileSystem::convertPath("{$dest_dir}/{$item}"); + if (is_dir($src_item)) { + $this->mergeDirContent($src_item, $dest_item); + } else { + FileSystem::createDir(dirname($dest_item)); + copy($src_item, $dest_item); + } + } + } + /** * Move file or directory, handling cross-device scenarios * Uses rename() if possible, falls back to copy+delete for cross-device moves diff --git a/src/StaticPHP/Artifact/Downloader/Type/Git.php b/src/StaticPHP/Artifact/Downloader/Type/Git.php index d5822e697..ad561c472 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/Git.php +++ b/src/StaticPHP/Artifact/Downloader/Type/Git.php @@ -20,7 +20,7 @@ public function download(string $name, array $config, ArtifactDownloader $downlo // direct branch clone if (isset($config['rev'])) { - default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null); + default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null, $downloader->getRetry()); $shell = PHP_OS_FAMILY === 'Windows' ? cmd(false) : shell(false); $hash_result = $shell->execWithResult(SPC_GIT_EXEC . ' -C ' . escapeshellarg($path) . ' rev-parse --short HEAD'); $hash = ($hash_result[0] === 0 && !empty($hash_result[1])) ? trim($hash_result[1][0]) : ''; @@ -66,7 +66,7 @@ public function download(string $name, array $config, ArtifactDownloader $downlo $version = array_key_first($matched_version_branch); $branch = $matched_version_branch[$version]; logger()->info("Matched version {$version} from branch {$branch} for {$name}"); - default_shell()->executeGitClone($config['url'], $branch, $path, $shallow, $config['submodules'] ?? null); + default_shell()->executeGitClone($config['url'], $branch, $path, $shallow, $config['submodules'] ?? null, $downloader->getRetry()); return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class); } throw new DownloaderException("No matching branch found for regex {$config['regex']} (checked {$matched_count} branches)."); diff --git a/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php b/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php index 15626089a..20db71275 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php +++ b/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php @@ -21,13 +21,13 @@ class GitHubRelease implements DownloadTypeInterface, ValidatorInterface, CheckU private ?string $version = null; - public function getGitHubReleases(string $name, string $repo, bool $prefer_stable = true, ?string $query = null): array + public function getGitHubReleases(string $name, string $repo, bool $prefer_stable = true, ?string $query = null, int $retries = 0): array { logger()->debug("Fetching {$name} GitHub releases from {$repo}"); $url = str_replace('{repo}', $repo, self::API_URL); $url .= ($query ?? ''); $headers = $this->getGitHubTokenHeaders(); - $data2 = default_shell()->executeCurl($url, headers: $headers); + $data2 = default_shell()->executeCurl($url, headers: $headers, retries: $retries); $data = json_decode($data2 ?: '', true); if (!is_array($data)) { throw new DownloaderException("Failed to get GitHub release API info for {$repo} from {$url}"); @@ -46,13 +46,13 @@ public function getGitHubReleases(string $name, string $repo, bool $prefer_stabl * Get the latest GitHub release assets for a given repository. * match_asset is provided, only return the asset that matches the regex. */ - public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_stable, string $match_asset, ?string $query = null): array + public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_stable, string $match_asset, ?string $query = null, int $retries = 0): array { logger()->debug("Fetching {$name} GitHub release from {$repo}"); $url = str_replace('{repo}', $repo, self::API_URL); $url .= ($query ?? ''); $headers = $this->getGitHubTokenHeaders(); - $data2 = default_shell()->executeCurl($url, headers: $headers); + $data2 = default_shell()->executeCurl($url, headers: $headers, retries: $retries); $data = json_decode($data2 ?: '', true); if (!is_array($data)) { throw new DownloaderException("Failed to get GitHub release API info for {$repo} from {$url}"); @@ -84,7 +84,7 @@ public function download(string $name, array $config, ArtifactDownloader $downlo if (!isset($config['match'])) { throw new DownloaderException("GitHubRelease downloader requires 'match' config for {$name}"); } - $rel = $this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match'], $config['query'] ?? null); + $rel = $this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match'], $config['query'] ?? null, $downloader->getRetry()); // download file using curl $asset_url = str_replace(['{repo}', '{id}'], [$config['repo'], $rel['id']], self::ASSET_URL); @@ -124,7 +124,7 @@ public function checkUpdate(string $name, array $config, ?string $old_version, A if (!isset($config['match'])) { throw new DownloaderException("GitHubRelease downloader requires 'match' config for {$name}"); } - $this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match'], $config['query'] ?? null); + $this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match'], $config['query'] ?? null, $downloader->getRetry()); $new_version = $this->version ?? $old_version ?? ''; return new CheckUpdateResult( old: $old_version, diff --git a/src/StaticPHP/Artifact/Downloader/Type/GitHubTarball.php b/src/StaticPHP/Artifact/Downloader/Type/GitHubTarball.php index 37ba5ca7a..c9c657bc8 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/GitHubTarball.php +++ b/src/StaticPHP/Artifact/Downloader/Type/GitHubTarball.php @@ -22,11 +22,11 @@ class GitHubTarball implements DownloadTypeInterface, CheckUpdateInterface * Get the GitHub tarball URL for a given repository and release type. * If match_url is provided, only return the tarball that matches the regex. */ - public function getGitHubTarballInfo(string $name, string $repo, string $rel_type, bool $prefer_stable = true, ?string $match_url = null, ?string $basename = null, ?string $query = null): array + public function getGitHubTarballInfo(string $name, string $repo, string $rel_type, bool $prefer_stable = true, ?string $match_url = null, ?string $basename = null, ?string $query = null, int $retries = 0): array { if ($rel_type === 'releases' && $match_url === null && $query === null && $prefer_stable) { $api_url = str_replace(['{repo}', '{rel_type}'], [$repo, 'releases/latest'], self::API_URL); - $data = default_shell()->executeCurl($api_url, headers: $this->getGitHubTokenHeaders()); + $data = default_shell()->executeCurl($api_url, headers: $this->getGitHubTokenHeaders(), retries: $retries); $data = json_decode($data ?: '', true); if (!is_array($data) || empty($data['tarball_url'])) { throw new DownloaderException("Failed to get GitHub latest release for {$repo} from {$api_url}"); @@ -36,7 +36,7 @@ public function getGitHubTarballInfo(string $name, string $repo, string $rel_typ } else { $api_url = str_replace(['{repo}', '{rel_type}'], [$repo, $rel_type], self::API_URL); $api_url .= ($query ?? ''); - $data = default_shell()->executeCurl($api_url, headers: $this->getGitHubTokenHeaders()); + $data = default_shell()->executeCurl($api_url, headers: $this->getGitHubTokenHeaders(), retries: $retries); $data = json_decode($data ?: '', true); if (!is_array($data)) { throw new DownloaderException("Failed to get GitHub tarball URL for {$repo} from {$api_url}"); @@ -65,7 +65,7 @@ public function getGitHubTarballInfo(string $name, string $repo, string $rel_typ } $this->version = $version ?? null; } - $head = default_shell()->executeCurl($rel_url, 'HEAD', headers: $this->getGitHubTokenHeaders()) ?: ''; + $head = default_shell()->executeCurl($rel_url, 'HEAD', headers: $this->getGitHubTokenHeaders(), retries: $retries) ?: ''; preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?.+\.tar\.gz)\1/im', $head, $matches); if ($matches) { $filename = $matches['filename']; @@ -84,9 +84,9 @@ public function download(string $name, array $config, ArtifactDownloader $downlo 'ghtagtar' => 'tags', default => throw new DownloaderException("Invalid GitHubTarball type for {$name}"), }; - [$url, $filename] = $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null); + [$url, $filename] = $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null, $downloader->getRetry()); $path = DOWNLOAD_PATH . "/{$filename}"; - default_shell()->executeCurlDownload($url, $path, headers: $this->getGitHubTokenHeaders()); + default_shell()->executeCurlDownload($url, $path, headers: $this->getGitHubTokenHeaders(), retries: $downloader->getRetry()); return DownloadResult::archive($filename, $config, $config['extract'] ?? null, version: $this->version, downloader: static::class); } @@ -97,7 +97,7 @@ public function checkUpdate(string $name, array $config, ?string $old_version, A 'ghtagtar' => 'tags', default => throw new DownloaderException("Invalid GitHubTarball type for {$name}"), }; - $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null); + $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null, $downloader->getRetry()); $new_version = $this->version ?? $old_version ?? ''; return new CheckUpdateResult( old: $old_version, diff --git a/src/StaticPHP/Artifact/DownloaderOptions.php b/src/StaticPHP/Artifact/DownloaderOptions.php index de2e91bf9..3e00d3b36 100644 --- a/src/StaticPHP/Artifact/DownloaderOptions.php +++ b/src/StaticPHP/Artifact/DownloaderOptions.php @@ -48,6 +48,7 @@ public static function getConsoleOptions(string $prefix = ''): array $shortU = $prefix ? null : 'U'; $shortG = $prefix ? null : 'G'; $shortL = $prefix ? null : 'L'; + $shortI = $prefix ? null : 'i'; return [ // php version option @@ -62,7 +63,7 @@ public static function getConsoleOptions(string $prefix = ''): array // download behavior options new InputOption("{$p}parallel", $shortP, InputOption::VALUE_REQUIRED, 'Number of parallel downloads (default 1)', '1'), new InputOption("{$p}retry", $shortR, InputOption::VALUE_REQUIRED, 'Number of download retries on failure (default 0)', '0'), - new InputOption("{$p}ignore-cache", null, InputOption::VALUE_OPTIONAL, 'Ignore some caches when downloading, comma separated, e.g "php-src,curl,openssl"', false), + new InputOption("{$p}ignore-cache", $shortI, InputOption::VALUE_OPTIONAL, 'Ignore some caches when downloading, comma separated, e.g "php-src,curl,openssl"', false), new InputOption("{$p}no-alt", null, null, 'Do not use alternative mirror download artifacts for sources'), new InputOption("{$p}no-shallow-clone", null, null, 'Do not clone shallowly repositories when downloading sources'), diff --git a/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php b/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php new file mode 100644 index 000000000..8621277cd --- /dev/null +++ b/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php @@ -0,0 +1,348 @@ + ['arch' => 'x86_64', 'runner' => 'ubuntu-latest', 'os_key' => 'Linux'], + 'windows' => ['arch' => 'x86_64', 'runner' => 'windows-latest', 'os_key' => 'Windows'], + 'macos' => ['arch' => 'aarch64', 'runner' => 'macos-15', 'os_key' => 'Darwin'], + ]; + + /** + * Tier 2 runners: Linux aarch64 + macOS x86_64, no Windows. + */ + private const array OS_RUNNERS_TIER2 = [ + 'linux' => ['arch' => 'aarch64', 'runner' => 'ubuntu-24.04-arm', 'os_key' => 'Linux'], + 'macos' => ['arch' => 'x86_64', 'runner' => 'macos-15-intel', 'os_key' => 'Darwin'], + ]; + + /** + * Extensions excluded from specific OS matrix entries. + */ + private const array OS_EXCLUDE = [ + 'linux' => ['glfw'], + ]; + + /** + * Extra build flags appended when a matrix entry contains any of the listed extensions. + * Key: extension display name (without ext- prefix). Value: extra flags string. + */ + private const array EXTRA_BUILD_FLAGS = [ + 'parallel' => '--enable-zts', + ]; + + /** + * Pairs of extensions that cannot be built together in the same matrix entry. + */ + private const array CONFLICTS = [ + ['grpc', 'protobuf'], + ['swow', 'swoole'], + ]; + + /** + * Extensions that must always appear alone in their own matrix entry. + * Use display names (without ext- prefix). + */ + private const array STANDALONE = [ + 'grpc', + 'glfw', + 'imagick', + 'intl', + ]; + + /** + * Maximum number of orphan extensions per matrix entry. + */ + private const int ORPHAN_BATCH_SIZE = 15; + + protected bool $no_motd = true; + + public function configure(): void + { + $this->addOption('for-extensions', null, InputOption::VALUE_OPTIONAL, 'Filter by extension display names, comma-separated', '') + ->addOption('for-libs', null, InputOption::VALUE_OPTIONAL, 'Filter by lib names (depends+suggests), comma-separated', '') + ->addOption('os', null, InputOption::VALUE_OPTIONAL, 'Filter by OS (Linux/Darwin/Windows), comma-separated', '') + ->addOption('tier2', null, InputOption::VALUE_NONE, 'Use Tier 2 runners (Linux aarch64 + macOS x86_64, no Windows)'); + } + + public function handle(): int + { + if (!spc_mode(SPC_MODE_SOURCE)) { + $this->output->writeln('This command is only available in source mode.'); + return static::USER_ERROR; + } + + $parse_option = fn (string $name): array => array_values(array_filter(array_map('trim', explode(',', (string) $this->input->getOption($name))))); + $filter_extensions = $parse_option('for-extensions'); + $filter_libs = $parse_option('for-libs'); + $filter_os_keys = $parse_option('os'); + $tier2 = (bool) $this->input->getOption('tier2'); + + $base_runners = $tier2 ? self::OS_RUNNERS_TIER2 : self::OS_RUNNERS; + + $all = PackageConfig::getAll(); + + // Separate into regular and virtual extensions (build-static:false excluded globally) + $all_regular = []; + $all_virtual = []; + foreach ($all as $pkg_name => $config) { + if (($config['type'] ?? '') !== 'php-extension') { + continue; + } + if (($config['php-extension']['build-static'] ?? null) === false) { + continue; + } + if (($config['php-extension']['arg-type'] ?? '') === 'none') { + $all_virtual[$pkg_name] = $config; + } else { + $all_regular[$pkg_name] = $config; + } + } + + $os_runners = empty($filter_os_keys) + ? $base_runners + : array_filter($base_runners, fn ($info) => in_array($info['os_key'], $filter_os_keys, true)); + + $entries = []; + $all_ext_lib_deps = []; + + foreach ($os_runners as $os => $os_info) { + $os_key = $os_info['os_key']; + + // Filter by OS support + $os_exclude = array_fill_keys(array_map(fn ($n) => 'ext-' . $n, self::OS_EXCLUDE[$os] ?? []), true); + $os_regular = array_filter($all_regular, fn ($c, $k) => $this->supportsOS($c, $os_key) && !isset($os_exclude[$k]), ARRAY_FILTER_USE_BOTH); + $os_virtual = array_filter($all_virtual, fn ($c, $k) => $this->supportsOS($c, $os_key) && !isset($os_exclude[$k]), ARRAY_FILTER_USE_BOTH); + + // Pool: all ext-* names available on this OS (regular + virtual) + $pool_set = array_fill_keys( + array_merge(array_keys($os_regular), array_keys($os_virtual)), + true + ); + + // Compute ext_deps for every pool member: union of depends + suggests, limited to pool + $ext_deps = []; + $os_lib_deps = []; + foreach (array_merge($os_regular, $os_virtual) as $pkg_name => $config) { + $raw = array_merge( + $this->resolvePlatformList($config, 'depends', $os), + $this->resolvePlatformList($config, 'suggests', $os), + ); + $ext_deps[$pkg_name] = array_values(array_filter( + $raw, + fn ($d) => isset($pool_set[$d]) && $d !== $pkg_name + )); + $os_lib_deps[$this->displayName($pkg_name)] = array_values(array_filter( + $raw, + fn ($d) => !str_starts_with($d, 'ext-') + )); + } + $all_ext_lib_deps[$os] = $os_lib_deps; + + // Which regular exts are reachable as a dep/suggest from another regular ext? + $depended_on = []; + foreach ($os_regular as $pkg_name => $_) { + foreach ($ext_deps[$pkg_name] as $dep) { + $depended_on[$dep] = true; + } + } + + // Process order: roots (not depended on) first, then non-roots; each group alpha-sorted + $roots = []; + $non_roots = []; + foreach (array_keys($os_regular) as $pkg_name) { + if (isset($depended_on[$pkg_name])) { + $non_roots[] = $pkg_name; + } else { + $roots[] = $pkg_name; + } + } + sort($roots); + sort($non_roots); + + // DFS to collect dependency chains; true orphans (no ext-* relations) are batched + $covered = []; + $groups = []; + $orphans = []; + $standalone_set = array_fill_keys(self::STANDALONE, true); + + foreach (array_merge($roots, $non_roots) as $ext) { + if (isset($covered[$ext])) { + continue; + } + $chain = $this->dfsCollect($ext, $ext_deps, $pool_set, $covered); + $display = $this->displayName($ext); + if (isset($standalone_set[$display])) { + // Always emit standalone extensions as their own single entry + $groups[] = $display; + } elseif (count($chain) === 1 && empty($ext_deps[$ext])) { + $orphans[] = $display; + } else { + $groups[] = implode(',', array_map($this->displayName(...), $chain)); + } + } + + // Batch orphans, splitting conflicting extensions into separate entries + if (!empty($orphans)) { + sort($orphans); + foreach ($this->splitOrphansByConflicts($orphans) as $batch) { + $groups[] = implode(',', $batch); + } + } + + sort($groups); + foreach ($groups as $group) { + $extra = $this->extraBuildFlags($group); + $entries[] = [ + 'runner' => $os_info['runner'], + 'os' => $os, + 'arch' => $os_info['arch'], + 'extension' => $group, + 'build-args' => './bin/spc build "' . $group . '" ' . self::BUILD_TARGETS . ($extra !== '' ? ' ' . $extra : ''), + ]; + } + } + + if (!empty($filter_extensions)) { + $entries = array_values(array_filter($entries, function (array $entry) use ($filter_extensions): bool { + $names = explode(',', $entry['extension']); + return count(array_intersect($names, $filter_extensions)) > 0; + })); + } + + if (!empty($filter_libs)) { + $entries = array_values(array_filter($entries, function (array $entry) use ($filter_libs, $all_ext_lib_deps): bool { + $names = explode(',', $entry['extension']); + $lib_deps = $all_ext_lib_deps[$entry['os']] ?? []; + foreach ($names as $name) { + if (count(array_intersect($lib_deps[$name] ?? [], $filter_libs)) > 0) { + return true; + } + } + return false; + })); + } + + $this->output->write(json_encode($entries, JSON_UNESCAPED_SLASHES)); + return static::SUCCESS; + } + + /** + * DFS-collect the dependency chain starting from $ext. + * Marks all visited nodes in $covered to prevent duplicates and handle cycles. + */ + private function dfsCollect(string $ext, array $ext_deps, array $pool_set, array &$covered): array + { + if (isset($covered[$ext])) { + return []; + } + $covered[$ext] = true; + $chain = [$ext]; + foreach ($ext_deps[$ext] ?? [] as $dep) { + if (!isset($covered[$dep]) && isset($pool_set[$dep])) { + $chain = array_merge($chain, $this->dfsCollect($dep, $ext_deps, $pool_set, $covered)); + } + } + return $chain; + } + + private function supportsOS(array $config, string $os_key): bool + { + $os_list = $config['php-extension']['os'] ?? null; + return $os_list === null || in_array($os_key, $os_list, true); + } + + private function displayName(string $pkg_name): string + { + return str_starts_with($pkg_name, 'ext-') ? substr($pkg_name, 4) : $pkg_name; + } + + /** + * Split orphans into batches such that no two conflicting extensions share a batch. + * Uses a greedy graph-coloring approach. + * + * @param string[] $orphans display names, pre-sorted + * @return string[][] array of batches, each batch is an array of display names + */ + private function splitOrphansByConflicts(array $orphans): array + { + $adjacency = []; + foreach (self::CONFLICTS as [$a, $b]) { + $adjacency[$a][$b] = true; + $adjacency[$b][$a] = true; + } + + $batches = []; + foreach ($orphans as $ext) { + $placed = false; + foreach ($batches as &$batch) { + if (count($batch) >= self::ORPHAN_BATCH_SIZE) { + continue; + } + $conflict = false; + foreach ($batch as $member) { + if (isset($adjacency[$ext][$member])) { + $conflict = true; + break; + } + } + if (!$conflict) { + $batch[] = $ext; + $placed = true; + break; + } + } + unset($batch); + if (!$placed) { + $batches[] = [$ext]; + } + } + return $batches; + } + + /** + * Returns any extra build flags required for an extension group string. + * Checks whether any extension in the comma-separated group matches EXTRA_BUILD_FLAGS. + */ + private function extraBuildFlags(string $group): string + { + $names = explode(',', $group); + $flags = []; + foreach (self::EXTRA_BUILD_FLAGS as $ext => $extra) { + if (in_array($ext, $names, true)) { + $flags[] = $extra; + } + } + return implode(' ', $flags); + } + + /** + * Resolve the value of a platform-specific array field, applying the suffix fallback chain. + * + * Fallback rules (same as PackageConfig::get): + * linux : @linux → @unix → (base) + * macos : @macos → @unix → (base) + * windows : @windows → (base) + */ + private function resolvePlatformList(array $config, string $field, string $platform): array + { + return match ($platform) { + 'linux' => $config["{$field}@linux"] ?? $config["{$field}@unix"] ?? $config[$field] ?? [], + 'macos' => $config["{$field}@macos"] ?? $config["{$field}@unix"] ?? $config[$field] ?? [], + 'windows' => $config["{$field}@windows"] ?? $config[$field] ?? [], + default => $config[$field] ?? [], + }; + } +} diff --git a/src/StaticPHP/Command/Dev/TestBotCommand.php b/src/StaticPHP/Command/Dev/TestBotCommand.php new file mode 100644 index 000000000..dc34444df --- /dev/null +++ b/src/StaticPHP/Command/Dev/TestBotCommand.php @@ -0,0 +1,313 @@ + 'Linux', + 'test/windows' => 'Windows', + 'test/macos' => 'Darwin', + ]; + + private const string TIER2_LABEL = 'test/tier2'; + + /** PHP version labels → version string (8.5 is always included as default) */ + private const array PHP_VERSION_LABELS = [ + 'test/php-83' => '8.3', + 'test/php-84' => '8.4', + ]; + + private const string DEFAULT_PHP_VERSION = '8.5'; + + protected bool $no_motd = true; + + public function configure(): void + { + $this->addOption('pr', null, InputOption::VALUE_REQUIRED, 'Pull request number') + ->addOption('repo', null, InputOption::VALUE_REQUIRED, 'Repository in owner/repo format (e.g. owner/repo)') + ->addOption('mock-files', null, InputOption::VALUE_REQUIRED, 'Comma-separated file paths to simulate PR changed files (skips GitHub API, for local testing)', '') + ->addOption('mock-labels', null, InputOption::VALUE_REQUIRED, 'Comma-separated labels to simulate PR labels (skips GitHub API, for local testing)', ''); + } + + public function handle(): int + { + $mock_files_raw = (string) $this->input->getOption('mock-files'); + $mock_labels_raw = (string) $this->input->getOption('mock-labels'); + $is_mock = $mock_files_raw !== '' || $mock_labels_raw !== ''; + + if ($is_mock) { + // Local testing mode: skip all GitHub API calls + $changed_files = array_map( + fn ($f) => ['filename' => trim($f)], + array_filter(explode(',', $mock_files_raw)) + ); + $label_names = array_map('trim', array_filter(explode(',', $mock_labels_raw))); + } else { + $pr = (int) $this->input->getOption('pr'); + $repo = (string) $this->input->getOption('repo'); + + if ($pr <= 0 || $repo === '') { + $this->output->writeln('Either --mock-files/--mock-labels (local test) or --pr and --repo (live) are required.'); + return static::USER_ERROR; + } + + $headers = array_merge( + $this->getGitHubTokenHeaders(), + ['Accept: application/vnd.github+json', 'X-GitHub-Api-Version: 2022-11-28'], + ); + + // Fetch changed files (paginated, up to 300) + $changed_files = $this->fetchPaginatedFiles($repo, $pr, $headers); + + // Fetch current labels on the PR/issue + $labels_raw = $this->apiGet( + sprintf('%s/repos/%s/issues/%d/labels', self::API_BASE, $repo, $pr), + $headers + ); + $label_names = array_column($labels_raw ?? [], 'name'); + } + + // Analyze changed files → extensions, libs, targets + [$extensions, $libs, $targets] = $this->analyzeChangedFiles($changed_files); + + // Resolve active platform OS keys (used as filters, not as trigger) + $os_keys = []; + foreach (self::PLATFORM_LABELS as $label => $os_key) { + if (in_array($label, $label_names, true)) { + $os_keys[] = $os_key; + } + } + $tier2 = in_array(self::TIER2_LABEL, $label_names, true); + $need_test = in_array('need-test', $label_names, true); + + // Resolve PHP versions (default always included) + $php_versions = [self::DEFAULT_PHP_VERSION]; + foreach (self::PHP_VERSION_LABELS as $label => $version) { + if (in_array($label, $label_names, true)) { + $php_versions[] = $version; + } + } + $php_versions = array_unique($php_versions); + sort($php_versions); + + // Build gen_matrix_args whenever need-test is set. + // Platform labels narrow the OS scope; absent = no --os filter (all platforms). + $gen_matrix_args = ''; + $gen_matrix_args_tier2 = ''; + if ($need_test) { + $flag_parts = []; + if (!empty($extensions)) { + $flag_parts[] = '--for-extensions=' . implode(',', $extensions); + } + if (!empty($libs)) { + $flag_parts[] = '--for-libs=' . implode(',', $libs); + } + if (!empty($os_keys)) { + $flag_parts[] = '--os=' . implode(',', $os_keys); + } + $gen_matrix_args = implode(' ', $flag_parts); + + if ($tier2) { + // Tier2 covers Linux + macOS only (never Windows) + $tier2_os = array_values(array_filter( + !empty($os_keys) ? $os_keys : ['Linux', 'Darwin'], + fn ($k) => $k !== 'Windows' + )); + if (!empty($tier2_os)) { + $tier2_parts = array_values(array_filter($flag_parts, fn ($f) => !str_starts_with($f, '--os='))); + $tier2_parts[] = '--os=' . implode(',', $tier2_os); + $tier2_parts[] = '--tier2'; + $gen_matrix_args_tier2 = implode(' ', $tier2_parts); + } + } + } + + $comment_body = $this->buildCommentBody( + $extensions, + $libs, + $targets, + $label_names, + $os_keys, + $tier2, + $php_versions, + $need_test, + ); + + $result = [ + 'need_test' => $need_test, + 'extensions' => array_values($extensions), + 'libs' => array_values($libs), + 'targets' => array_values($targets), + 'gen_matrix_args' => $gen_matrix_args, + 'gen_matrix_args_tier2' => $gen_matrix_args_tier2, + 'php_versions' => array_values($php_versions), + 'tier2' => $tier2, + 'comment_body' => $comment_body, + ]; + + $this->output->write(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + return static::SUCCESS; + } + + /** + * Fetch all changed files for a PR across up to 3 pages (max 300 files). + */ + private function fetchPaginatedFiles(string $repo, int $pr, array $headers): array + { + $files = []; + for ($page = 1; $page <= 3; ++$page) { + $url = sprintf('%s/repos/%s/pulls/%d/files?per_page=100&page=%d', self::API_BASE, $repo, $pr, $page); + $batch = $this->apiGet($url, $headers); + if (empty($batch)) { + break; + } + $files = array_merge($files, $batch); + if (count($batch) < 100) { + break; + } + } + return $files; + } + + /** + * Perform a GET request and return decoded JSON array, or null on failure. + */ + private function apiGet(string $url, array $headers): ?array + { + $data = default_shell()->executeCurl($url, headers: $headers); + $decoded = json_decode($data ?: '', true); + return is_array($decoded) ? $decoded : null; + } + + /** + * Analyze changed file paths and classify them into extensions, libs, and targets. + * + * @return array{string[], string[], string[]} + */ + private function analyzeChangedFiles(array $files): array + { + $extensions = []; + $libs = []; + $targets = []; + + foreach ($files as $file) { + $path = $file['filename'] ?? ''; + + if (preg_match('#^src/Package/Extension/([^/]+)\.php$#', $path, $m)) { + $name = strtolower($m[1]); + $extensions[$name] = $name; + } elseif (preg_match('#^config/pkg/ext/ext-([^/]+)\.yml$#', $path, $m)) { + $extensions[$m[1]] = $m[1]; + } elseif (preg_match('#^src/Package/Library/([^/]+)\.php$#', $path, $m)) { + $name = strtolower($m[1]); + $libs[$name] = $name; + } elseif (preg_match('#^config/pkg/lib/([^/]+)\.yml$#', $path, $m)) { + $libs[$m[1]] = $m[1]; + } elseif (preg_match('#^src/Package/Target/([^/]+)\.php$#', $path, $m)) { + $name = strtolower($m[1]); + $targets[$name] = $name; + } elseif (preg_match('#^config/pkg/target/([^/]+)\.yml$#', $path, $m)) { + $targets[$m[1]] = $m[1]; + } + } + + sort($extensions); + sort($libs); + sort($targets); + + return [$extensions, $libs, $targets]; + } + + private function buildCommentBody( + array $extensions, + array $libs, + array $targets, + array $label_names, + array $os_keys, + bool $tier2, + array $php_versions, + bool $need_test, + ): string { + $fmt = static fn (array $items): string => !empty($items) + ? '`' . implode('`, `', $items) . '`' + : '_none_'; + + $detected = sprintf( + '**Detected**: Extensions: %s | Libraries: %s | Targets: %s', + $fmt($extensions), + $fmt($libs), + $fmt($targets), + ); + + // Case 1: need-test absent → invite the author to add it + if (!$need_test) { + return implode("\n", [ + '', + '**StaticPHP Test Bot**', + '', + $detected, + '', + 'To trigger extension build tests on this PR, add the `need-test` label:', + '', + '**Gate**: `need-test`', + '**Platform filter** (optional, default all): `test/linux` `test/windows` `test/macos` · `test/tier2`', + '**PHP version** (optional, default 8.5): `test/php-83` `test/php-84`', + ]); + } + + // Case 2: need-test present → show what will run + // os_keys empty = no filter = all platforms + $effective_os = !empty($os_keys) + ? $os_keys + : array_values(self::PLATFORM_LABELS); // all OS keys + + $platform_parts = []; + foreach (self::PLATFORM_LABELS as $_label => $os_key) { + if (!in_array($os_key, $effective_os, true)) { + continue; + } + $platform_parts[] = match ($os_key) { + 'Linux' => 'Linux x86_64', + 'Darwin' => 'macOS arm64', + /* @phpstan-ignore-next-line */ + 'Windows' => 'Windows x86_64', + default => $os_key, + }; + } + if ($tier2) { + if (in_array('Linux', $effective_os, true)) { + $platform_parts[] = 'Linux aarch64 (Tier2)'; + } + if (in_array('Darwin', $effective_os, true)) { + $platform_parts[] = 'macOS x86_64 (Tier2)'; + } + } + + $php_str = implode(', ', array_map(fn ($v) => "PHP {$v}", $php_versions)) . ' NTS'; + $active_test_labels = array_values(array_filter($label_names, fn ($l) => str_starts_with($l, 'test/'))); + $labels_str = !empty($active_test_labels) ? '`' . implode('`, `', $active_test_labels) . '`' : '_none_'; + + return implode("\n", [ + '', + '**StaticPHP Test Bot**', + '', + $detected, + '**Active labels**: ' . $labels_str, + '**Config**: ' . implode(' + ', $platform_parts) . ' | ' . $php_str, + ]); + } +} diff --git a/src/StaticPHP/ConsoleApplication.php b/src/StaticPHP/ConsoleApplication.php index 06234f769..cf305e05b 100644 --- a/src/StaticPHP/ConsoleApplication.php +++ b/src/StaticPHP/ConsoleApplication.php @@ -13,11 +13,13 @@ use StaticPHP\Command\Dev\EnvCommand; use StaticPHP\Command\Dev\GenDepsDataCommand; use StaticPHP\Command\Dev\GenExtDocsCommand; +use StaticPHP\Command\Dev\GenExtTestMatrixCommand; use StaticPHP\Command\Dev\IsInstalledCommand; use StaticPHP\Command\Dev\LintConfigCommand; use StaticPHP\Command\Dev\PackageInfoCommand; use StaticPHP\Command\Dev\PackLibCommand; use StaticPHP\Command\Dev\ShellCommand; +use StaticPHP\Command\Dev\TestBotCommand; use StaticPHP\Command\DoctorCommand; use StaticPHP\Command\DownloadCommand; use StaticPHP\Command\DumpExtensionsCommand; @@ -85,6 +87,8 @@ public function __construct() new PackageInfoCommand(), new GenExtDocsCommand(), new GenDepsDataCommand(), + new GenExtTestMatrixCommand(), + new TestBotCommand(), ]); // add additional commands from registries diff --git a/src/StaticPHP/Package/PackageInstaller.php b/src/StaticPHP/Package/PackageInstaller.php index 750ddbaf0..9cd0a1067 100644 --- a/src/StaticPHP/Package/PackageInstaller.php +++ b/src/StaticPHP/Package/PackageInstaller.php @@ -350,7 +350,10 @@ public function isPackageInstalled(Package|string $package_name): bool } // Fallback: if the download cache is missing (e.g. download failed or cache was cleared), // still check whether the files are physically present in buildroot. - if ($package instanceof LibraryPackage) { + // Note: TargetPackage extends LibraryPackage, but target packages (e.g. zig) have no + // static-libs/headers configured, so isInstalled() would trivially return true for them. + // Only apply this fallback to pure library packages. + if ($package instanceof LibraryPackage && !($package instanceof TargetPackage)) { return $package->isInstalled(); } return false; diff --git a/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php b/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php index ecf17693c..82fa468ae 100644 --- a/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php +++ b/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php @@ -230,7 +230,7 @@ private function getDefaultCMakeArgs(): array // EXE linker flags: base system libs + framework flags for target packages $exeLinkerFlags = SystemTarget::getRuntimeLibs(); - if ($this->package instanceof TargetPackage) { + if ($this->package instanceof TargetPackage && SystemTarget::getTargetOS() === 'Darwin') { $resolvedNames = array_keys($this->installer->getResolvedPackages()); $resolvedNames[] = $this->package->getName(); $fwFlags = new SPCConfigUtil()->getFrameworksString($resolvedNames); diff --git a/src/StaticPHP/Runtime/Shell/DefaultShell.php b/src/StaticPHP/Runtime/Shell/DefaultShell.php index 8f77f62d7..f20fca339 100644 --- a/src/StaticPHP/Runtime/Shell/DefaultShell.php +++ b/src/StaticPHP/Runtime/Shell/DefaultShell.php @@ -44,6 +44,7 @@ public function executeCurl(string $url, string $method = 'GET', array $headers $cmd = SPC_CURL_EXEC . " -sfSL --max-time 3600 {$retry_arg} {$compressed_arg} {$method_arg} {$header_arg} {$url_arg}"; $this->logCommandInfo($cmd); + logger()->debug("[CURL EXECUTE] {$cmd}"); $result = $this->passthru($cmd, capture_output: true, throw_on_error: false); $ret = $result['code']; $output = $result['output']; @@ -83,7 +84,7 @@ public function executeCurlDownload(string $url, string $path, array $headers = /** * Execute a Git clone command to clone a repository. */ - public function executeGitClone(string $url, string $branch, string $path, bool $shallow = true, ?array $submodules = null): void + public function executeGitClone(string $url, string $branch, string $path, bool $shallow = true, ?array $submodules = null, int $retries = 0): void { $path = FileSystem::convertPath($path); if (file_exists($path)) { @@ -98,7 +99,21 @@ public function executeGitClone(string $url, string $branch, string $path, bool $cmd = clean_spaces("{$git} clone -c http.lowSpeedLimit=1 -c http.lowSpeedTime=3600 --config core.autocrlf=false --branch {$branch_arg} {$shallow_arg} {$submodules_arg} {$url_arg} {$path_arg}"); $this->logCommandInfo($cmd); logger()->debug("[GIT CLONE] {$cmd}"); - $this->passthru($cmd, $this->console_putput); + try { + $this->passthru($cmd, $this->console_putput); + } catch (InterruptException $e) { + throw $e; + } catch (\Throwable $e) { + if ($retries > 0) { + logger()->warning("Git clone failed, retrying... ({$retries} retries left)"); + if (is_dir($path)) { + FileSystem::removeDir($path); + } + $this->executeGitClone($url, $branch, $path, $shallow, $submodules, $retries - 1); + return; + } + throw $e; + } if ($submodules !== null) { $depth_flag = $shallow ? '--depth 1' : ''; foreach ($submodules as $submodule) {