diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e3466c4..d97746f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,11 +173,17 @@ jobs: - name: Download embedded PHP files shell: bash run: | - php_version=$(grep -m1 '^PHP_VERSION' Makefile | cut -d' ' -f3) + # Line endings may be converted on checkout. + var() { tr -d '\r' < Makefile | sed -n "s/^$1 = //p"; } + php_version=$(var PHP_VERSION) + asset="php-$php_version-windows-amd64.exe" + php_sha256=$(tr -d '\r' < internal/legacy/php_sha256sums.txt | awk -v a="$asset" '$2 == a { print $1 }') mkdir -p internal/legacy/archives - curl -fSL "https://github.com/upsun/cli-php-builds/releases/download/php-$php_version/php-$php_version-windows-amd64.exe" \ + curl -fSL "https://github.com/upsun/cli-php-builds/releases/download/php-$php_version-$(var PHP_BUILD)/$asset" \ -o internal/legacy/archives/php_windows.exe - curl -fSL https://curl.se/ca/cacert.pem -o internal/legacy/archives/cacert.pem + curl -fSL "https://curl.se/ca/cacert-$(var CACERT_DATE).pem" -o internal/legacy/archives/cacert.pem + echo "$php_sha256 internal/legacy/archives/php_windows.exe" | sha256sum -c - + echo "$(var CACERT_SHA256) internal/legacy/archives/cacert.pem" | sha256sum -c - # Only needed so that the package builds. touch internal/legacy/archives/platform.phar touch internal/legacy/archives/commands-upsun.json internal/legacy/archives/commands-platformsh.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef909bfc..275225cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,10 +70,6 @@ jobs: - name: Install legacy dependencies run: cd legacy && composer install --no-interaction - - name: Get PHP version from Makefile - id: php-version - run: echo "version=$(grep '^PHP_VERSION' Makefile | cut -d= -f2 | tr -d ' ')" >> $GITHUB_OUTPUT - - name: Get tool versions from Makefile id: tool-versions run: | @@ -87,7 +83,7 @@ jobs: path: | internal/legacy/archives/php_* internal/legacy/archives/php-version - key: php-binaries-v2-${{ steps.php-version.outputs.version }} + key: php-binaries-v3-${{ hashFiles('internal/legacy/php_sha256sums.txt') }} - name: Download all PHP binaries if: steps.php-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/update-php.yml b/.github/workflows/update-php.yml new file mode 100644 index 00000000..dc9002bd --- /dev/null +++ b/.github/workflows/update-php.yml @@ -0,0 +1,94 @@ +# Opens or updates a pull request when there is a new build of the embedded PHP +# (from upsun/cli-php-builds) or a new CA bundle. See scripts/update-php.sh. +name: Update PHP + +on: + schedule: + - cron: "30 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: update-php + +env: + BRANCH: automated/update-php + +jobs: + update: + runs-on: ubuntu-latest + # Holds the credentials of the GitHub App that opens the pull request. + environment: php-updates + + steps: + - name: Check out repository code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Check for updates + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + scripts/update-php.sh "$RUNNER_TEMP/title" "$RUNNER_TEMP/body" + if [[ -f "$RUNNER_TEMP/title" ]]; then + echo "title=$(cat "$RUNNER_TEMP/title")" >> "$GITHUB_OUTPUT" + fi + + # Skips pushing again (and re-running CI) if the branch already has this update. + - name: Check the existing branch + id: existing + if: steps.check.outputs.title + env: + TITLE: ${{ steps.check.outputs.title }} + run: | + if git fetch --depth=1 origin "$BRANCH" 2>/dev/null \ + && [[ "$(git log -1 --format=%s FETCH_HEAD)" == "$TITLE" ]]; then + echo "up_to_date=true" >> "$GITHUB_OUTPUT" + fi + + - name: Verify the downloads + if: steps.check.outputs.title && !steps.existing.outputs.up_to_date + run: | + make download-php + expected=$(sed -n 's/^PHP_VERSION = //p' Makefile) + actual=$(internal/legacy/archives/php_linux_amd64 -r 'echo PHP_VERSION;') + if [[ "$actual" != "$expected" ]]; then + echo "::error::Expected PHP $expected, got $actual" + exit 1 + fi + + - name: Generate GitHub App token + id: app-token + if: steps.check.outputs.title && !steps.existing.outputs.up_to_date + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + # Pushes with the App token, so that CI runs on the pull request. + - name: Push and open or update the pull request + if: steps.check.outputs.title && !steps.existing.outputs.up_to_date + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + TITLE: ${{ steps.check.outputs.title }} + run: | + user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + printf '%s\n\n' "$TITLE" | cat - "$RUNNER_TEMP/body" | git commit -a -F - + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/$BRANCH" + + pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') + if [[ -n "$pr" ]]; then + gh pr edit "$pr" --title "$TITLE" --body-file "$RUNNER_TEMP/body" + else + gh pr create --base "$GITHUB_REF_NAME" --head "$BRANCH" --title "$TITLE" --body-file "$RUNNER_TEMP/body" + fi diff --git a/CLAUDE.md b/CLAUDE.md index 9bca2399..3cb96c2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,11 +128,12 @@ The CLI operates as a wrapper around a legacy PHP CLI: - GoReleaser builds multiple variants (upsun, platform, vendor-specific) **PHP Binary Handling**: -- PHP binaries are downloaded from [upsun/cli-php-builds](https://github.com/upsun/cli-php-builds) releases +- PHP binaries are downloaded from [upsun/cli-php-builds](https://github.com/upsun/cli-php-builds) releases, tagged `php--`. They are rebuilt (with a new build number) when PHP or a library has a new release +- Their SHA-256 checksums are pinned in `internal/legacy/php_sha256sums.txt` (the release's `SHA256SUMS.txt`), and verified after download - All platforms use static binaries built with [static-php-cli](https://github.com/crazywhalecc/static-php-cli) - Supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 - Extensions included: curl, filter, openssl, pcntl (Unix), phar, posix (Unix), zlib -- Windows requires `cacert.pem` for OpenSSL (embedded separately) +- Windows requires `cacert.pem` for OpenSSL (embedded separately), pinned by date and checksum (`CACERT_DATE`, `CACERT_SHA256`) **Downloading PHP Binaries**: ```bash @@ -144,10 +145,8 @@ make download-php ``` **Upgrading PHP Version**: -1. Trigger the build workflow at [upsun/cli-php-builds](https://github.com/upsun/cli-php-builds/actions) with the new PHP version -2. Update `PHP_VERSION` in the Makefile -3. Run `make php` (or `make download-php`) to download the new binaries -4. Test and release +- The `update-php` workflow runs daily: it finds the latest cli-php-builds release for the current PHP minor version, and the latest CA bundle, and opens or updates a pull request that updates `PHP_VERSION`, `PHP_BUILD`, the checksums and the CA bundle (with `scripts/update-php.sh`). The pull request lists the changed source archives (from the releases' `sources.json`) +- For a new PHP minor version: trigger the build workflow at [upsun/cli-php-builds](https://github.com/upsun/cli-php-builds/actions) with the new version, then set `PHP_VERSION` to it and `PHP_BUILD` to `0` (builds are numbered from 1), run `scripts/update-php.sh `, and `make download-php` to check the downloads ## Development Notes diff --git a/Makefile b/Makefile index 5a2813da..abb1f10a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ +# The PHP version, and the cli-php-builds build number (rebuilt when a library is updated). +# Checksums of the binaries are pinned in PHP_SHA256SUMS. Updated by the update-php workflow. PHP_VERSION = 8.5.11 +PHP_BUILD = 1 + +# The CA bundle embedded for PHP on Windows, from https://curl.se/docs/caextract.html. +CACERT_DATE = 2026-09-25 +CACERT_SHA256 = a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505 .DEFAULT_GOAL := help @@ -29,7 +36,19 @@ REPOGEN_VERSION=v1.0.4 # PHP binaries are downloaded from cli-php-builds releases. # See: https://github.com/upsun/cli-php-builds PHP_BUILDS_REPO = upsun/cli-php-builds -PHP_RELEASE_URL = https://github.com/$(PHP_BUILDS_REPO)/releases/download/php-$(PHP_VERSION) +PHP_RELEASE_TAG = php-$(PHP_VERSION)-$(PHP_BUILD) +PHP_RELEASE_URL = https://github.com/$(PHP_BUILDS_REPO)/releases/download/$(PHP_RELEASE_TAG) +PHP_SHA256SUMS = internal/legacy/php_sha256sums.txt + +# Verifies a downloaded file against a checksum. Usage: $(call verify-sha256,file,checksum) +SHA256SUM := $(shell command -v sha256sum 2>/dev/null || echo shasum -a 256) +define verify-sha256 + @test -n "$(2)" || { echo "No checksum for $(1)" >&2; exit 1; } + echo "$(2) $(1)" | $(SHA256SUM) -c - +endef + +# The pinned checksum of a PHP release asset. Usage: $(call php-sha256,asset-name) +php-sha256 = $(shell awk '$$2 == "$(1)" { print $$1 }' $(PHP_SHA256SUMS)) # Files embedded in the Go binary. ARCHIVES = internal/legacy/archives @@ -113,17 +132,19 @@ $(ARCHIVES)/commands-vendor.json: $(ARCHIVES)/platform.phar internal/config/embe legacy/vendor/autoload.php: cd legacy && composer install --no-interaction -# Records PHP_VERSION, so that the PHP binaries are downloaded again when it changes. +# Records the PHP release tag, so that the PHP binaries are downloaded again when it changes. $(ARCHIVES)/php-version: FORCE @mkdir -p $(ARCHIVES) - @echo $(PHP_VERSION) | cmp -s - $@ || echo $(PHP_VERSION) > $@ + @echo $(PHP_RELEASE_TAG) | cmp -s - $@ || echo $(PHP_RELEASE_TAG) > $@ $(PHP_UNIX_BINARIES): $(ARCHIVES)/php_%: $(ARCHIVES)/php-version curl -fSL "$(PHP_RELEASE_URL)/php-$(PHP_VERSION)-$(subst _,-,$*)" -o $@ + $(call verify-sha256,$@,$(call php-sha256,php-$(PHP_VERSION)-$(subst _,-,$*))) chmod +x $@ $(ARCHIVES)/php_windows.exe: $(ARCHIVES)/php-version curl -fSL "$(PHP_RELEASE_URL)/php-$(PHP_VERSION)-windows-amd64.exe" -o $@ + $(call verify-sha256,$@,$(call php-sha256,php-$(PHP_VERSION)-windows-amd64.exe)) .PHONY: $(ARCHIVES)/php_windows_amd64 $(ARCHIVES)/php_windows_amd64: $(ARCHIVES)/php_windows.exe $(ARCHIVES)/cacert.pem @@ -131,7 +152,8 @@ $(ARCHIVES)/php_windows_amd64: $(ARCHIVES)/php_windows.exe $(ARCHIVES)/cacert.pe .PHONY: $(ARCHIVES)/cacert.pem $(ARCHIVES)/cacert.pem: mkdir -p $(ARCHIVES) - curl -fSL https://curl.se/ca/cacert.pem -o $@ + curl -fSL https://curl.se/ca/cacert-$(CACERT_DATE).pem -o $@ + $(call verify-sha256,$@,$(CACERT_SHA256)) .PHONY: FORCE FORCE: diff --git a/internal/legacy/php_sha256sums.txt b/internal/legacy/php_sha256sums.txt new file mode 100644 index 00000000..b61bf900 --- /dev/null +++ b/internal/legacy/php_sha256sums.txt @@ -0,0 +1,6 @@ +26af30fdde5cd3ea5032415ce5aea40e5a0c69d52c4cc0cc8d5c669592fd9c27 php-8.5.11-darwin-amd64 +8899461b8796b03f807bc71661650790aaf2a98a4ac5d59fe1520cda5f4212d2 php-8.5.11-darwin-arm64 +a3806545776bbac282211346700f3467a7ccde8b44771095d61a11f265978a01 php-8.5.11-linux-amd64 +e0968165a094ed374e3d134f6a048e667dba92b5df40b1e5962a48b47ee9b63b php-8.5.11-linux-arm64 +d034a9a3a1fc6505723d5c879f82c414406615ea64c172afbb2b9de4ce098441 php-8.5.11-windows-amd64.exe +e6659656c6df2be75caef66f403ecf10b7933b65d87a81edd904db2b880fa55f sources.json diff --git a/scripts/update-php.sh b/scripts/update-php.sh new file mode 100755 index 00000000..08e409a7 --- /dev/null +++ b/scripts/update-php.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Updates the pinned PHP build (in the Makefile and the checksums file) to the latest cli-php-builds release for the +# current PHP minor version, and the pinned CA bundle to the latest from curl.se. +# Writes a pull request title and body to the files given as arguments, if anything changed. +# Requires: gh (authenticated), jq, curl, sha256sum. +set -euo pipefail + +title_file=${1:?Usage: $0 TITLE_FILE BODY_FILE} +body_file=${2:?Usage: $0 TITLE_FILE BODY_FILE} + +repo=upsun/cli-php-builds +sums_file=internal/legacy/php_sha256sums.txt + +makefile_var() { + sed -n "s/^$1 = //p" Makefile +} + +set_makefile_var() { + sed -i.bak "s/^$1 = .*/$1 = $2/" Makefile && rm Makefile.bak +} + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +php_version=$(makefile_var PHP_VERSION) +php_build=$(makefile_var PHP_BUILD) +cacert_date=$(makefile_var CACERT_DATE) +# Builds are numbered from 1, so PHP_BUILD = 0 (for a new PHP version) always updates. +old_tag="php-$php_version-$php_build" +minor=$(cut -d. -f1,2 <<< "$php_version") + +new_tag=$(gh release list --repo "$repo" --exclude-drafts --exclude-pre-releases --limit 100 \ + --json tagName --jq '.[].tagName' \ + | grep -E "^php-${minor//./\\.}\.[0-9]+-[0-9]+$" | sort -V | tail -n1 || true) +if [[ -z "$new_tag" ]]; then + echo "No release found for PHP $minor" >&2 + exit 1 +fi + +new_cacert_date=$(curl -fsSL https://curl.se/docs/caextract.html \ + | grep -oE 'cacert-[0-9]{4}-[0-9]{2}-[0-9]{2}\.pem' | sed 's/^cacert-//; s/\.pem$//' | sort | tail -n1) +if [[ -z "$new_cacert_date" ]]; then + echo "No CA bundle found" >&2 + exit 1 +fi + +titles=() +body="" + +if [[ "$new_tag" != "$old_tag" ]]; then + if [[ "$(sort -V <<< "$old_tag"$'\n'"$new_tag" | tail -n1)" != "$new_tag" ]]; then + echo "The latest release $new_tag is older than $old_tag" >&2 + exit 1 + fi + new_version=$(sed -E 's/^php-(.*)-[0-9]+$/\1/' <<< "$new_tag") + new_build=${new_tag##*-} + + gh release download "$new_tag" --repo "$repo" --dir "$tmp/new" --pattern SHA256SUMS.txt --pattern sources.json + if ! gh release download "$old_tag" --repo "$repo" --dir "$tmp/old" --pattern sources.json; then + mkdir -p "$tmp/old" && echo '{}' > "$tmp/old/sources.json" + fi + + cp "$tmp/new/SHA256SUMS.txt" "$sums_file" + set_makefile_var PHP_VERSION "$new_version" + set_makefile_var PHP_BUILD "$new_build" + + if [[ "$new_version" == "$php_version" ]]; then + titles+=("rebuild PHP $new_version (build $new_build)") + else + titles+=("upgrade PHP to $new_version (build $new_build)") + fi + + # Lists changed source archives, one row per change, with the platforms it applies to. + changes=$(jq -rn --slurpfile old "$tmp/old/sources.json" --slurpfile new "$tmp/new/sources.json" ' + $old[0] as $o | $new[0] as $n + | [($o + $n | keys[]) as $p | (($o[$p] // {}) + ($n[$p] // {}) | keys[]) as $lib + | {p: $p, lib: $lib, old: ($o[$p][$lib] // "-"), new: ($n[$p][$lib] // "-")} + | select(.old != .new)] + | group_by([.lib, .old, .new]) + | map("| \(.[0].lib) | \(.[0].old) | \(.[0].new) | \(map(.p) | join(", ")) |") + | .[]') + body+="Updates the embedded PHP binaries from [$old_tag](https://github.com/$repo/releases/tag/$old_tag)" + body+=" to [$new_tag](https://github.com/$repo/releases/tag/$new_tag)."$'\n\n' + if [[ -n "$changes" ]]; then + body+="| Source | Old | New | Platforms |"$'\n'"|---|---|---|---|"$'\n'"$changes"$'\n\n' + else + body+="No source archives changed."$'\n\n' + fi +fi + +if [[ "$new_cacert_date" > "$cacert_date" ]]; then + file="cacert-$new_cacert_date.pem" + curl -fsSL "https://curl.se/ca/$file" -o "$tmp/$file" + curl -fsSL "https://curl.se/ca/$file.sha256" -o "$tmp/$file.sha256" + (cd "$tmp" && sha256sum -c "$file.sha256") + set_makefile_var CACERT_DATE "$new_cacert_date" + set_makefile_var CACERT_SHA256 "$(cut -d' ' -f1 "$tmp/$file.sha256")" + titles+=("update the CA bundle to $new_cacert_date") + body+="Updates the CA bundle (embedded for PHP on Windows) from $cacert_date to $new_cacert_date." + body+=" See: https://curl.se/docs/caextract.html"$'\n\n' +fi + +if [[ ${#titles[@]} -eq 0 ]]; then + echo "Up to date: $old_tag, CA bundle $cacert_date" + exit 0 +fi + +title="build: ${titles[0]}" +if [[ ${#titles[@]} -gt 1 ]]; then + title+=" and ${titles[1]}" +fi +echo "$title" > "$title_file" +printf '%s' "$body" > "$body_file" +echo "$title"