From 9ff3595e3858362dcb9e7db8249ef367e60c1593 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 21:39:03 +0100 Subject: [PATCH 1/2] build: pin PHP builds and checksums, and update them automatically Download PHP from the cli-php-builds tag php-$(PHP_VERSION)-$(PHP_BUILD), as each rebuild (for a PHP or library release) now gets a new build number. Pin the SHA-256 checksums of the PHP binaries in internal/legacy/php_sha256sums.txt (a copy of the release's SHA256SUMS.txt), and pin the CA bundle by date and checksum, instead of downloading the latest unverified. Downloads are verified in the Makefile and in the Windows CI job. The release cache key now depends on the checksums. Add a daily update-php workflow, which runs scripts/update-php.sh to find the latest build for the current PHP minor version and the latest CA bundle, verifies the downloads, and opens or updates a pull request with a GitHub App token (so that CI runs on it). The pull request lists the changed source archives, from the releases' sources.json. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 12 ++- .github/workflows/release.yml | 6 +- .github/workflows/update-php.yml | 94 ++++++++++++++++++++++++ CLAUDE.md | 11 ++- Makefile | 30 +++++++- internal/legacy/php_sha256sums.txt | 6 ++ scripts/update-php.sh | 113 +++++++++++++++++++++++++++++ 7 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/update-php.yml create mode 100644 internal/legacy/php_sha256sums.txt create mode 100755 scripts/update-php.sh 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..1e5f5210 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`, 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..ceb5232b --- /dev/null +++ b/scripts/update-php.sh @@ -0,0 +1,113 @@ +#!/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) +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, grouped by platform. + 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" From bb490493c0b9a3c410c4534ef6c0a9525b0d80d7 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 22:38:33 +0100 Subject: [PATCH 2/2] docs: clarify PHP build numbering and the sources table Builds in cli-php-builds are numbered from 1, so PHP_BUILD = 0 never matches an existing release, and the script always updates the checksums. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- scripts/update-php.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e5f5210..3cb96c2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ make download-php **Upgrading PHP Version**: - 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`, run `scripts/update-php.sh `, and `make download-php` to check the downloads +- 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/scripts/update-php.sh b/scripts/update-php.sh index ceb5232b..08e409a7 100755 --- a/scripts/update-php.sh +++ b/scripts/update-php.sh @@ -25,6 +25,7 @@ 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") @@ -69,7 +70,7 @@ if [[ "$new_tag" != "$old_tag" ]]; then titles+=("upgrade PHP to $new_version (build $new_build)") fi - # Lists changed source archives, grouped by platform. + # 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