From 2b9dea7e6ba34f66a22d2ac0960f506558cb5712 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Aug 2026 20:08:15 +0000 Subject: [PATCH 01/22] Point the submodule at the bundled libmaxminddb build mode maxmind/MaxMind-DB-Reader-php#265 added ext/libmaxminddb and the --with-maxminddb-bundled configure flag. The extension objects this repository publishes are built with that flag, so that they depend on nothing but the C runtime and a user installing a prebuilt binary needs no libmaxminddb of their own. It also brings the shared dev-bin scripts the release workflow runs: gate-extension.sh and verify-extension.php, whose headers name this repository as the second caller. Co-Authored-By: Claude Opus 5 (1M context) --- MaxMind-DB-Reader-php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MaxMind-DB-Reader-php b/MaxMind-DB-Reader-php index 2194f58..0b33422 160000 --- a/MaxMind-DB-Reader-php +++ b/MaxMind-DB-Reader-php @@ -1 +1 @@ -Subproject commit 2194f58d0f024ce923e685cdf92af3daf9951908 +Subproject commit 0b33422b4cdbc629c95d2f14f41f3b11c4d64076 From 23f65f37ef3a3febfecd4121d8415da5e353994d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Aug 2026 20:08:16 +0000 Subject: [PATCH 02/22] Add the scripts that build, gate and stage a release asset Four steps of the release, kept out of the workflow so they can be read, run and fixed on their own, and so the Linux and macOS lanes cannot drift apart by expressing the same step twice in YAML. build-ext.sh compiles the extension from the submodule's ext/ with --with-maxminddb-bundled and records the PHP ABI and thread-safety the build actually produced, rather than what the matrix claimed. gate-extension.ps1 is the Windows half of the self-containment gate, whose Unix half lives in the submodule. It checks the DLL exports get_module with dumpbin and then loads it and queries a database -- the build does not notice an extension PHP cannot load, since the phpt suite skips when the extension is missing, which is how a DLL with no get_module export once shipped. stage-asset.sh names the zip the way PIE's pre-packaged-binary method expects and cross-checks the recorded ABI, thread safety and architecture against the matrix, so an asset cannot be published under a name that lies about what is inside it. collect-assets.sh gathers one asset per staged artifact and asserts the total against what the matrix said it would produce. A release that quietly ships a source tarball and no binaries looks successful and is not. Co-Authored-By: Claude Opus 5 (1M context) --- dev-bin/build-ext.sh | 34 ++++++++++ dev-bin/collect-assets.sh | 64 ++++++++++++++++++ dev-bin/gate-extension.ps1 | 133 +++++++++++++++++++++++++++++++++++++ dev-bin/stage-asset.sh | 60 +++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100755 dev-bin/build-ext.sh create mode 100755 dev-bin/collect-assets.sh create mode 100755 dev-bin/gate-extension.ps1 create mode 100755 dev-bin/stage-asset.sh diff --git a/dev-bin/build-ext.sh b/dev-bin/build-ext.sh new file mode 100755 index 0000000..c18b0d5 --- /dev/null +++ b/dev-bin/build-ext.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Build maxminddb.so from the submodule's ext/ sources and record the fields the +# published asset name is derived from. Used by both release lanes: on Linux +# inside a digest-pinned container, on macOS directly on the runner. +# +# Usage: build-ext.sh + +set -euo pipefail + +ext_dir="$1" +out_dir="$2" + +# --with-maxminddb-bundled compiles libmaxminddb's vendored sources into the +# extension so the published .so needs nothing but libc. Without those sources +# ./configure silently falls back to looking for a system library and we would +# ship a binary with a dangling libmaxminddb dependency, so stop here instead. +if [ ! -f "$ext_dir/libmaxminddb/src/maxminddb.c" ]; then + echo "::error::$ext_dir/libmaxminddb is missing. The submodule must point at a tag that supports --with-maxminddb-bundled." + exit 1 +fi + +mkdir -p "$out_dir" +out_dir="$(cd "$out_dir" && pwd)" # absolute, because the build cd's away +cd "$ext_dir" +phpize +./configure --with-maxminddb --with-maxminddb-bundled +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" +cp modules/maxminddb.so "$out_dir/maxminddb.so" + +# Record these as reported by the PHP that actually built the object, so the +# asset filename cannot disagree with the binary inside it. +php-config --version | cut -d. -f1,2 > "$out_dir/php-abi" +php -r 'echo ZEND_THREAD_SAFE ? "-zts" : "";' > "$out_dir/ts-suffix" diff --git a/dev-bin/collect-assets.sh b/dev-bin/collect-assets.sh new file mode 100755 index 0000000..2321b31 --- /dev/null +++ b/dev-bin/collect-assets.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Collect one release asset out of each staged artifact directory and assert +# that the set is complete. Run by verify-assets on every run, and again by +# publish immediately before it uploads, so a pull request exercises the same +# assertion a release depends on. +# +# Usage: collect-assets.sh +# +# Reads TAG, BINARIES, PHP_VERSIONS, TS_MODES, LINUX_ARCHES and WINDOWS_COUNT. + +set -euo pipefail + +dist="$1" +assets="$2" + +fail() { + echo "::error::$*" + exit 1 +} + +find "$dist" -type f | sort + +# Every build job stages exactly one file at its artifact root. The Windows +# action additionally stages a logs/ subdirectory, so select only files one +# level below an artifact directory and leave the logs out of the release. +mkdir -p "$assets" +find "$dist" -mindepth 2 -maxdepth 2 -type f -exec cp -t "$assets/" {} + + +staged="$(find "$dist" -mindepth 2 -maxdepth 2 -type f | wc -l)" +collected="$(find "$assets" -maxdepth 1 -type f | wc -l)" +[ "$staged" -eq "$collected" ] || + fail "Two artifacts contain the same filename ($staged staged, $collected collected)." + +php_count="$(jq 'length' <<<"$PHP_VERSIONS")" +ts_count="$(jq 'length' <<<"$TS_MODES")" +arch_count="$(jq 'length' <<<"$LINUX_ARCHES")" +if [ "$BINARIES" = true ]; then + # Linux (php x ts x arch) + macOS (php x ts). + binary_count=$((php_count * ts_count * arch_count + php_count * ts_count)) +else + # Those two lanes were skipped for want of bundled libmaxminddb sources, so + # expect none of their assets. setup only allows this on a non-release run. + binary_count=0 +fi +# 1 source tarball + the binary lanes + whatever the Windows matrix said it +# would produce. Every term comes from setup, which is also what each matrix +# expands, so the expectation cannot drift from what was built -- including when +# setup hands out the reduced pull-request lists or skips the binary lanes. +expected=$((1 + binary_count + WINDOWS_COUNT)) + +echo "Expecting $expected assets, found $collected:" +find "$assets" -maxdepth 1 -type f -printf ' %f\n' | sort + +# This assertion is the point of the job. A release that silently ships the +# source tarball and zero binaries -- because an artifact glob matched nothing +# -- looks successful and is not. +[ "$collected" -eq "$expected" ] || + fail "Expected $expected release assets but found $collected. Refusing to publish a partial release." + +# Named explicitly because every platform without a prebuilt binary depends on +# this one file. +[ -f "$assets/maxminddb-${TAG}.tgz" ] || + fail "$assets/maxminddb-${TAG}.tgz is missing." diff --git a/dev-bin/gate-extension.ps1 b/dev-bin/gate-extension.ps1 new file mode 100755 index 0000000..cdf0957 --- /dev/null +++ b/dev-bin/gate-extension.ps1 @@ -0,0 +1,133 @@ +#!/usr/bin/env pwsh +# +# The Windows half of dev-bin/gate-extension.sh: refuse to publish a +# php_maxminddb.dll that PHP will not load. Written in PowerShell rather than +# added to the bash gate because nothing it uses -- dumpbin, the PHP the build +# downloaded -- exists on the Unix lanes, and nothing the bash gate uses exists +# here. +# +# Usage: gate-extension.ps1 +# +# is the directory php/php-windows-builder was told to build in. It +# builds under a per-run subdirectory of that, which is where both the DLL and +# the php-bin it was built against are found. +# +# There is deliberately no Windows analogue of the .so's libmaxminddb NEEDED +# check. The libmaxminddb that PHP publishes for Windows is a static +# libmaxminddb.lib with no DLL beside it, so the extension carries no +# libmaxminddb import whether it was built from the bundled sources or against +# the fetched library, and an assertion that always holds would tell us nothing +# about which one we built. + +param( + [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Directory the extension was built in')] + [string] $BuildRoot, + [Parameter(Mandatory = $true, Position = 1, HelpMessage = 'Path to verify-extension.php')] + [string] $Verifier, + [Parameter(Mandatory = $true, Position = 2, HelpMessage = 'Path to a test database')] + [string] $Database, + [Parameter(Mandatory = $true, Position = 3, HelpMessage = 'Expected MMDB_LIB_VERSION')] + [string] $ExpectedVersion +) + +$ErrorActionPreference = 'Stop' +# Every exit code below is checked and reported by hand, so keep PowerShell from +# turning one into a bare NativeCommandExitException before it gets there. The +# default differs between PowerShell versions; this does not. +$PSNativeCommandUseErrorActionPreference = $false + +function Fail([string] $Message) { + Write-Host "::error::$Message" + throw $Message +} + +function Find-Only([string] $What, $Candidates) { + $found = @($Candidates | Where-Object { $null -ne $_ }) + if ($found.Count -ne 1) { + Fail "Expected exactly one $What under $BuildRoot, found $($found.Count)." + } + return $found[0].FullName +} + +foreach ($path in @($BuildRoot, $Verifier, $Database)) { + if (-not (Test-Path -LiteralPath $path)) { + Fail "$path does not exist." + } +} + +# -ErrorAction SilentlyContinue on the walks below because the build tree holds +# unpacked PHP and SDK archives whose paths can be too long to enumerate. A path +# that could not be walked shows up as a file that was not found, which fails. +$dll = Find-Only 'php_maxminddb.dll' ( + Get-ChildItem -LiteralPath $BuildRoot -Recurse -File -Filter 'php_maxminddb.dll' ` + -ErrorAction SilentlyContinue +) +# The PHP the extension was built against, and the only one whose version, +# thread safety and architecture are guaranteed to match it. +$php = Find-Only 'php-bin\php.exe' ( + Get-ChildItem -LiteralPath $BuildRoot -Recurse -File -Filter 'php.exe' ` + -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -eq 'php-bin' } +) + +Write-Host "Gating $dll with $php" + +# 1. The DLL must export get_module. This is the direct form of the failure the +# gate exists for: an object path collision under phpize overwrote the +# extension's own object with libmaxminddb's, configure, nmake and the +# packaging step all accepted the result, and PHP then rejected it at startup +# as "Invalid library (maybe not a PHP library)". +# +# dumpbin ships with MSVC but is not on PATH outside a developer prompt, and +# this step is not one. vswhere is installed with every Visual Studio and is +# the supported way to ask where it went; prefer the x64-hosted copy, which +# reads DLLs of either architecture. +$dumpbin = $null +$command = Get-Command dumpbin.exe -CommandType Application -ErrorAction SilentlyContinue +if ($null -ne $command) { + $dumpbin = $command.Source +} elseif ($null -ne ${env:ProgramFiles(x86)}) { + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path -LiteralPath $vswhere) { + $candidates = @(& $vswhere -latest -prerelease -products * -find '**\dumpbin.exe') + if ($candidates.Count -eq 0) { + # -find needs vswhere 2.6; fall back to the documented toolset path + # under whichever Visual Studio vswhere reports. + $installed = & $vswhere -latest -prerelease -products * -property installationPath + if ($installed) { + $glob = Join-Path $installed 'VC\Tools\MSVC\*\bin\Host*\*\dumpbin.exe' + $candidates = @(Resolve-Path -Path $glob -ErrorAction SilentlyContinue | + ForEach-Object { $_.Path }) + } + } + $dumpbin = $candidates | Where-Object { $_ -match 'Hostx64\\x64' } | Select-Object -First 1 + if ($null -eq $dumpbin) { + $dumpbin = $candidates | Select-Object -First 1 + } + } +} +if ($null -eq $dumpbin) { + Fail 'Found no dumpbin.exe, so the export table cannot be read.' +} + +Write-Host "Reading exports with $dumpbin" +$exports = & $dumpbin /nologo /exports $dll +if ($LASTEXITCODE -ne 0) { + Fail "dumpbin could not read $dll (exit $LASTEXITCODE)." +} +$export = $exports | Select-String -Pattern '\bget_module\b' | Select-Object -First 1 +if ($null -eq $export) { + Fail "$dll exports no get_module, so PHP would reject it as not a PHP library." +} +Write-Host "get_module export: $($export.Line.Trim())" + +# 2. Load it and query a real database, the equivalent of the Linux lane's +# clean-container run. `-n` so no php.ini can supply anything the DLL needs, +# and verify-extension.php exits non-zero when the extension is not loaded: +# PHP only warns about a library it could not use and would otherwise leave +# the exit code at 0, which is the quietness this gate is here to end. +Write-Host 'Loading the extension and querying a database' +& $php -n -d "extension=$dll" $Verifier $Database $ExpectedVersion +if ($LASTEXITCODE -ne 0) { + Fail "$dll did not load and query cleanly (exit $LASTEXITCODE)." +} diff --git a/dev-bin/stage-asset.sh b/dev-bin/stage-asset.sh new file mode 100755 index 0000000..3eda225 --- /dev/null +++ b/dev-bin/stage-asset.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Name and zip a gated object as a PIE pre-packaged-binary asset, then append +# ASSET= to $GITHUB_ENV for the upload step. +# +# Usage: stage-asset.sh +# +# Reads TAG, MATRIX_PHP, MATRIX_TS and MATRIX_ARCH. + +set -euo pipefail + +out_dir="$1" +stage_dir="$2" +os="$3" +libc="$4" + +fail() { + echo "::error::$*" + exit 1 +} + +abi="$(cat "$out_dir/php-abi")" +ts_suffix="$(cat "$out_dir/ts-suffix")" + +# Cross-check the binary against the matrix. If a pinned image or a setup-php +# input were wrong, the asset would otherwise be published under a name that +# lies about its contents, and PIE would hand users an object built for a +# different PHP. +[ "$abi" = "$MATRIX_PHP" ] || + fail "Built PHP is $abi but the matrix says $MATRIX_PHP; the build environment is wrong." +if [ "$MATRIX_TS" = "zts" ]; then expected_suffix="-zts"; else expected_suffix=""; fi +[ "$ts_suffix" = "$expected_suffix" ] || + fail "Built PHP thread-safety '$ts_suffix' disagrees with matrix '$MATRIX_TS'." + +case "$(uname -m)" in +x86_64) arch=x86_64 ;; +aarch64 | arm64) arch=arm64 ;; +*) fail "Unsupported machine $(uname -m)." ;; +esac +[ "$arch" = "$MATRIX_ARCH" ] || fail "Runner reports $arch but the matrix says $MATRIX_ARCH." + +# PIE's pre-packaged-binary method looks for, all lowercased: +# php_{ext}-{version}_php{maj.min}-{arch}-{os}-{libc}[-zts].zip +# {version} is Composer's pretty version. Packagist reports this package as +# v1.13.1, so the v-prefixed tag goes in verbatim. +name="php_maxminddb-${TAG}_php${abi}-${arch}-${os}-${libc}${ts_suffix}.zip" +name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + +mkdir -p "$stage_dir" +stage_dir="$(cd "$stage_dir" && pwd)" # absolute, because zip runs from out_dir +# -j so maxminddb.so lands at the archive root: PIE copies that one file out and +# installs nothing else. +( cd "$out_dir" && zip -j "$stage_dir/$name" maxminddb.so ) + +contents="$(unzip -Z1 "$stage_dir/$name")" +[ "$contents" = "maxminddb.so" ] || + fail "Archive must contain exactly maxminddb.so at its root, got: $contents" + +echo "Staged $name" +echo "ASSET=$stage_dir/$name" >> "$GITHUB_ENV" From 13b63c14ce162805298f3c21ed8d1e4255e1a94d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Aug 2026 20:08:35 +0000 Subject: [PATCH 03/22] Build and publish PIE precompiled binaries on tag A `v*` tag builds maxminddb.so for Linux x86_64 and arm64, macOS arm64 and Windows across PHP 8.2 to 8.5 in both thread-safety modes, plus the source tarball, and attaches them to the release. PIE then downloads a prebuilt object on those platforms and falls back to building from source everywhere else. Everything except the release itself also runs on a pull request, so a change to this workflow is tested by the change that makes it rather than by the next tag. What a pull request skips is only what writes: draft-release, the upload and un-draft steps, and smoke, which installs from Packagist and would have nothing to install. contents: write belongs to two jobs, neither reachable without a tag. Each object is gated before it is staged: nothing but the C runtime in NEEDED, no RUNPATH, no undefined or exported MMDB_ symbols, get_module still exported, a measured glibc floor inside a documented ceiling, and a load and query of a real database in a container with nothing else mounted. Linux and macOS run the gate the submodule provides, so this repository and MaxMind-DB-Reader-php hold the same objects to the same bar; Windows runs the PowerShell half. The source tarball is built from an explicit member list rather than a glob, which would publish the vendored checkout's git metadata, test suite and nested test-data submodules. A source-build job then builds that tarball in a container with only the extracted directory mounted, because every other lane builds from the submodule tree and a libmaxminddb reshuffle would otherwise break source-installing users while every other gate stayed green. publish asserts the exact expected asset count before uploading and refuses a partial release. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 944 ++++++++++++++++++++++++++++++++++ 1 file changed, 944 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..89bce49 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,944 @@ +name: Release + +on: + # A pull request builds and gates everything and touches no release, so the + # workflow is exercised by the changes that break it rather than only by the + # next tag. No bare `push`: every branch here becomes a pull request, which + # would then run everything twice. + pull_request: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to build and publish assets for; empty for a dry run" + required: false + type: string + full-matrix: + description: "Build the full matrix on a dry run (a tag always does)" + required: false + default: false + type: boolean + +# Escalated per job: only draft-release and publish get contents: write, and +# neither is reachable on a pull request. +permissions: {} + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + # A superseded pull-request run is worth cancelling; a release in flight is + # not. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + setup: + name: Resolve version and build lists + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + release: ${{ steps.resolve.outputs.release }} + binaries: ${{ steps.resolve.outputs.binaries }} + tag: ${{ steps.resolve.outputs.tag }} + ext-ref: ${{ steps.resolve.outputs.ext-ref }} + php-versions: ${{ steps.resolve.outputs.php-versions }} + ts-modes: ${{ steps.resolve.outputs.ts-modes }} + linux-arches: ${{ steps.resolve.outputs.linux-arches }} + mmdb-version: ${{ steps.resolve.outputs.mmdb-version }} + checkout-ref: ${{ steps.resolve.outputs.checkout-ref }} + steps: + # One level of submodule: enough to read its pointer and to see whether + # its tree carries ext/libmaxminddb. Not recursive -- the only thing + # wanted from libmaxminddb's own contents is its version, and that one + # nested submodule is initialised below rather than pulling MaxMind-DB's + # test data with it. + # inputs.tag only has a value on a workflow_dispatch re-cut; empty falls + # back to the triggering ref, which is already the tag on a tag push. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag }} + submodules: true + persist-credentials: false + + - name: Check out the bundled libmaxminddb + run: git -C MaxMind-DB-Reader-php submodule update --init ext/libmaxminddb + + - name: Resolve the version and the build lists + id: resolve + env: + # workflow_dispatch lets assets be re-cut without re-tagging; leaving + # it empty asks for a dry run of everything but the release itself. + TAG: ${{ inputs.tag }} + REF_NAME: ${{ github.ref_name }} + PR: ${{ github.event.pull_request.number }} + FULL_MATRIX: ${{ inputs.full-matrix }} + run: | + # On a tag push inputs.tag is empty and the ref name is the tag. + if [ -z "$TAG" ] && [ "$GITHUB_EVENT_NAME" = push ]; then + TAG="$REF_NAME" + fi + + # A tag makes this a release run: draft-release, publish and smoke + # become reachable and the full matrix is mandatory, since publish + # refuses to ship a partial release. Everything else -- every pull + # request, and a dispatch with no tag -- builds and gates the same + # assets and then stops. + if [ -n "$TAG" ]; then + release=true + full=true + else + release=false + full="${FULL_MATRIX:-false}" + # No tag exists, so stand in a version that cannot be mistaken for + # a release. It keeps the leading v because every asset name embeds + # this string verbatim and PIE matches on Composer's v-prefixed + # pretty version: the naming code must not learn a second rule. + TAG="v0.0.0-${PR:+pr${PR}-}${GITHUB_SHA::7}" + fi + + # The tag is interpolated into asset filenames, so constrain it to + # characters that are safe there before anything else uses it. The + # stand-in version goes through this too rather than around it. + if [[ ! "$TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + echo "::error::Refusing to build: tag '$TAG' is not a safe filename component." + exit 1 + fi + + # The Windows lane builds maxmind/MaxMind-DB-Reader-php directly + # rather than this repository's checkout of it, and no tag of that + # name exists there on a dry run, so point it at the exact commit the + # submodule -- and therefore every other lane -- builds. + if [ "$release" = true ]; then + ext_ref="$TAG" + else + ext_ref="$(git rev-parse HEAD:MaxMind-DB-Reader-php)" + fi + + # The Linux, macOS and Windows lanes compile libmaxminddb's vendored + # sources, which exist only once ext/libmaxminddb is part of the + # submodule tree. Absence stays fatal for a release -- a release whose + # binaries are silently missing is worse than no release, and + # build-ext.sh says so -- but a pull request should not be red for a + # dependency that has not landed yet, so those two lanes skip instead, + # the same way source-build already skips. Windows cannot skip, + # because it is the only source of the windows-binary assets, so it + # falls back to linking the system library instead. + if [ "$release" = true ] || + [ -n "$(git -C MaxMind-DB-Reader-php ls-tree HEAD ext/libmaxminddb)" ]; then + binaries=true + else + binaries=false + echo "::warning::The submodule carries no libmaxminddb sources; skipping the Linux and macOS lanes." + fi + + # Single source of truth for every lane's matrix *and* for the + # asset-count assertion, so the two cannot drift. + php='["8.2","8.3","8.4","8.5"]' + ts='["nts","zts"]' + arches='["x86_64","arm64"]' + if [ "$full" != true ]; then + # The knob. A dry run is there to prove the workflow still works, + # not to certify every ABI, and 41 assets is a lot to spend on a + # link fix. Dropping PHP versions is the reduction that costs no + # coverage of the workflow itself: both TS modes and both Linux + # architectures stay, because those are the legs whose steps + # differ, and macOS (php x ts) and Windows (php-version-list) both + # still run. 8.4 is the version source-build already pins an image + # for. Widen by adding versions here, or dispatch with full-matrix. + php='["8.4"]' + fi + + # Every other job checks this out. On a release it is the tag, so a + # workflow_dispatch re-cut builds the tagged commit rather than + # whatever the dispatching branch happens to point at -- which used to + # mean Linux, macOS and the tarball came from the branch while Windows + # built the tag, and the mixture was published under the tag's name. + # Empty on every other run, which leaves actions/checkout on its + # default. + if [ "$release" = true ]; then + checkout_ref="$TAG" + else + checkout_ref="" + fi + + # The version the bundled sources will report as MMDB_LIB_VERSION. + # Every load check compares against it, which catches a PACKAGE_VERSION + # that never reached the compiler as well as a stale bump. + mmdb_version="$(sed -n 's/^AC_INIT(\[libmaxminddb\], \[\([^]]*\)\].*/\1/p' \ + MaxMind-DB-Reader-php/ext/libmaxminddb/configure.ac)" + if [ -z "$mmdb_version" ]; then + echo "::error::Could not read the libmaxminddb version from the submodule." + exit 1 + fi + + echo "Building $TAG (release=$release, binaries=$binaries, full matrix=$full, libmaxminddb $mmdb_version)." + { + echo "release=$release" + echo "binaries=$binaries" + echo "tag=$TAG" + echo "ext-ref=$ext_ref" + echo "php-versions=$php" + echo "ts-modes=$ts" + echo "linux-arches=$arches" + echo "mmdb-version=$mmdb_version" + echo "checkout-ref=$checkout_ref" + } >> "$GITHUB_OUTPUT" + + draft-release: + name: Find or create the draft release + if: needs.setup.outputs.release == 'true' + needs: [setup] + runs-on: ubuntu-24.04 + permissions: + contents: write # create the draft release + steps: + - name: Find or create the draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.setup.outputs.tag }} + run: | + # Assets cannot be added to a published, immutable release, so the + # whole run stages onto a draft that publish flips at the very end. + # + # Reusing an existing release therefore has to check that it is still + # a draft, not merely that it exists: `gh release view` succeeds for + # published releases too, and staging onto one would replace bytes + # under a tag users may already have installed. + # + # A 404 is the only result that means "not there yet"; every other + # failure -- auth, rate limit, a 5xx -- must not be mistaken for it. + if body="$(gh api "repos/${GH_REPO}/releases/tags/${TAG}" 2>"$RUNNER_TEMP/gh.err")"; then + if [ "$(jq -r .draft <<<"$body")" != true ]; then + echo "::error::Release $TAG exists and is already published; refusing to stage assets onto it." + exit 1 + fi + echo "Draft release $TAG already exists; reusing it." + exit 0 + elif ! grep -q 'HTTP 404' "$RUNNER_TEMP/gh.err"; then + cat "$RUNNER_TEMP/gh.err" + echo "::error::Could not determine whether release $TAG exists; refusing to guess." + exit 1 + fi + # Releases here are titled without the tag's leading "v": v1.13.1 is + # titled 1.13.1. Only the title drops it -- the tag itself and every + # asset filename keep it, because PIE matches assets on Composer's + # pretty version, which is v-prefixed. + title="${TAG#v}" + if ! gh release create "$TAG" --draft --title "$title" --notes-from-tag; then + # --notes-from-tag only works for annotated tags. + echo "::warning::--notes-from-tag failed; falling back to generated notes." + gh release create "$TAG" --draft --title "$title" --generate-notes + fi + + source-tarball: + name: Source tarball + needs: [setup] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + submodules: recursive + persist-credentials: false + + - name: Build the flat source tarball + env: + TAG: ${{ needs.setup.outputs.tag }} + run: | + # This is the pre-packaged-source fallback that every platform without + # a prebuilt binary uses, so both the layout and the name have to keep + # working exactly as they do today: taring from inside ext/ puts + # config.m4 and its siblings at the archive root with no wrapping + # directory, which is where PIE looks and matches the layout of the + # tarballs published for this package before this workflow existed. + # + # Members are listed explicitly rather than swept up with a bare `*` + # glob because ext/libmaxminddb is a recursive submodule checkout: a + # glob publishes its .git gitfile, .github/, t/, bin/, doc/ and + # autotools scaffolding, plus the two MaxMind-DB test-data submodules + # nested under it. A source build reads only the sources config.m4 + # compiles (libmaxminddb/src/*.c) and the headers it puts on the + # include path; LICENSE and NOTICE ship because those sources do -- + # libmaxminddb is Apache-2.0 and Apache-2.0 4(d) requires a + # redistribution to carry the NOTICE content it publishes. + # + # One member deliberately comes from outside ext/: the extension's + # own LICENSE, which sits at the repository root a level up. A source + # distribution has to carry its own Apache-2.0 licence and not only + # its dependency's, so tar appends it with `-C ..` and it lands flat + # at the archive root like everything else. MaxMind-DB-Reader-php + # publishes no NOTICE of its own. + # + # An inline `run:` gets GitHub's default `bash -e {0}` -- no pipefail + # -- and the member list is generated on the left of a pipe. Without + # this, a `find` that fails partway through kills the group, the + # remaining members are never emitted, `sort` still exits 0, and the + # step produces a silently incomplete tarball. + set -o pipefail + mkdir -p "$RUNNER_TEMP/stage" + tarball="$RUNNER_TEMP/stage/maxminddb-${TAG}.tgz" + members="$RUNNER_TEMP/members" + ( cd MaxMind-DB-Reader-php/ext + { + find . -mindepth 1 -maxdepth 1 ! -name libmaxminddb -printf '%P\n' + # Keyed on a source file config.m4 actually compiles, not on the + # directory: an uninitialised nested submodule leaves an empty + # ext/libmaxminddb behind, which `-d` would accept and which + # would then contribute no members at all. + if [ -f libmaxminddb/src/maxminddb.c ]; then + find libmaxminddb/src -maxdepth 1 -name '*.[ch]' -print + find libmaxminddb/include -maxdepth 1 -name '*.h' -print + echo libmaxminddb/LICENSE + echo libmaxminddb/NOTICE + fi + } | sort > "$members" + # tests/ is a directory and is still archived recursively; + # --verbatim-files-from keeps tar from reading a member name that + # starts with a dash as an option. + tar -czf "$tarball" --verbatim-files-from -T "$members" -C .. LICENSE ) + echo "TARBALL=$tarball" >> "$GITHUB_ENV" + + - name: Verify the tarball layout + run: | + listing="$(tar -tzf "$TARBALL")" + echo "$listing" + echo "$(wc -l <<<"$listing") entries" + + fail() { echo "::error::$*"; exit 1; } + + grep -qx 'config.m4' <<<"$listing" || + fail "config.m4 is not at the tarball root; PIE would not find the sources." + + # Appended from the repository root rather than swept up from inside + # ext/, so assert it landed flat here and not as ../LICENSE. + grep -qx 'LICENSE' <<<"$listing" || + fail "The extension's own LICENSE is not at the tarball root." + + # Neither the vendored checkout's own git metadata nor its test suite + # and CI configuration belong in a published release asset. + if grep -E '(^|/)\.git(/|$)' <<<"$listing"; then + fail "The tarball contains git metadata." + fi + if grep -E '^libmaxminddb/(t|\.github)/' <<<"$listing"; then + fail "The tarball contains libmaxminddb's tests or CI configuration." + fi + + # Once the --with-maxminddb-bundled PR lands, ext/libmaxminddb/ is + # part of the submodule tree and what config.m4 compiles from it must + # end up in the tarball too, along with the license and notice that + # redistributing those sources obliges us to carry. + if grep -q '^libmaxminddb/' <<<"$listing"; then + for member in libmaxminddb/src/maxminddb.c libmaxminddb/include/maxminddb.h \ + libmaxminddb/LICENSE libmaxminddb/NOTICE; do + grep -qx "$member" <<<"$listing" || + fail "libmaxminddb/ is in the tarball but $member is missing." + done + echo "Bundled libmaxminddb sources are present." + else + echo "::warning::No bundled libmaxminddb sources in the submodule yet." + fi + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-asset-source-tarball + path: ${{ env.TARBALL }} + if-no-files-found: error + + source-build: + name: Build from the source tarball + # Only source-tarball is needed, so this runs alongside the binary lanes + # instead of extending the critical path. publish waits for it because a + # tarball that cannot be built must not reach users as the + # pre-packaged-source fallback. + needs: [setup, source-tarball] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + # The binary lanes all build from the submodule tree, so a libmaxminddb + # reshuffle -- headers moved into a subdirectory, a new source file added + # -- would produce a tarball that fails only for source-installing users + # while every other gate stayed green. This job builds what those users + # actually download. + # + # One PHP is enough: what is being tested is the tarball's file list, + # which does not vary by PHP version, and the Linux lane already builds + # every version. Same digest as that lane's 8.4 nts image. + IMAGE: php:8.4-cli-bookworm@sha256:8f0c382c6483a202baaad042c9ad51064f050de1093e5e8db990d055dd18534f + steps: + # Only for the submodule's verify-extension.php and the test database in + # step. The build below mounts the extraction directory and nothing else, + # so this checkout is not reachable from the compiler. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + submodules: recursive + persist-credentials: false + + - name: Download the staged source tarball + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-asset-source-tarball + path: ${{ runner.temp }}/artifact + + - name: Extract the tarball into an empty directory + id: extract + env: + TAG: ${{ needs.setup.outputs.tag }} + # setup sets this when there were bundled sources to build -- always + # on a release. It is the oracle for whether the tarball is *supposed* + # to carry libmaxminddb, which the tarball itself cannot answer. + BINARIES: ${{ needs.setup.outputs.binaries }} + run: | + tarball="$RUNNER_TEMP/artifact/maxminddb-${TAG}.tgz" + if [ ! -f "$tarball" ]; then + echo "::error::$tarball is missing from the staged artifact." + exit 1 + fi + + # A fresh directory, so what gets built is the tarball's contents and + # nothing else: no stray config.m4, no headers and no libmaxminddb + # left over from a checkout. + src="$RUNNER_TEMP/src" + rm -rf "$src" + mkdir -p "$src" + tar -xzf "$tarball" -C "$src" + find "$src" -mindepth 1 -maxdepth 1 -printf '%P\n' | sort + + # The tarball is flat, so this is where phpize has to run. + if [ ! -f "$src/config.m4" ]; then + echo "::error::config.m4 is not at the extraction root." + exit 1 + fi + + # Until ext/libmaxminddb is part of the submodule tree the tarball + # carries no libmaxminddb sources at all and a bundled build is + # impossible, so warn and skip rather than fail -- the same way the + # source-tarball verification warns. + # + # But only when there was nothing to package. Once setup says there + # was -- which it always does on a release -- a tarball missing those + # sources is the exact defect this job exists to catch, and skipping + # on it would turn the test off precisely when it would have failed. + if [ -f "$src/libmaxminddb/src/maxminddb.c" ]; then + echo "bundled=true" >> "$GITHUB_OUTPUT" + elif [ "$BINARIES" = true ]; then + echo "::error::The submodule carries libmaxminddb but the tarball does not; the packaging step dropped it." + exit 1 + else + echo "bundled=false" >> "$GITHUB_OUTPUT" + echo "::warning::The tarball carries no libmaxminddb sources; skipping the source build." + fi + + - name: Build from the tarball alone + if: steps.extract.outputs.bundled == 'true' + run: | + cat > "$RUNNER_TEMP/build.sh" <<'BUILD' + set -euo pipefail + # Assert the isolation rather than trusting the base image: a system + # libmaxminddb here would let a tarball that is missing sources link + # against it and pass anyway. + if pkg-config --exists libmaxminddb 2>/dev/null; then + echo "::error::The build image has a system libmaxminddb; this job proves nothing." + exit 1 + fi + if ls /usr/include/maxminddb.h /usr/lib/*/libmaxminddb.* /usr/local/lib/libmaxminddb.* 2>/dev/null; then + echo "::error::The build image has libmaxminddb headers or libraries; this job proves nothing." + exit 1 + fi + + cd /src + phpize + ./configure --with-maxminddb --with-maxminddb-bundled + make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" + test -f modules/maxminddb.so + BUILD + + # /src is the only thing mounted from the runner besides the script + # itself, so the workspace checkout cannot satisfy an include or a + # link that the tarball failed to ship. + docker run --rm \ + -v "$RUNNER_TEMP/src:/src" \ + -v "$RUNNER_TEMP/build.sh:/build.sh:ro" \ + -e HOME=/tmp \ + "$IMAGE" bash /build.sh + + - name: Load and query the object built from the tarball + if: steps.extract.outputs.bundled == 'true' + env: + MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} + run: | + # The clean-container check the binary lanes use, pointed at the + # object the tarball produced: nothing mounted but the .so, the check + # and a test database, and `php -n` so no php.ini can supply anything. + docker run --rm \ + -v "$RUNNER_TEMP/src/modules/maxminddb.so:/tmp/maxminddb.so:ro" \ + -v "$GITHUB_WORKSPACE/MaxMind-DB-Reader-php/dev-bin/verify-extension.php:/tmp/verify.php:ro" \ + -v "$GITHUB_WORKSPACE/MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb:/tmp/test.mmdb:ro" \ + "$IMAGE" \ + php -n -d extension=/tmp/maxminddb.so /tmp/verify.php /tmp/test.mmdb "$MMDB_VERSION" + + linux: + name: Linux ${{ matrix.arch }} PHP ${{ matrix.php-version }} ${{ matrix.ts }} + if: needs.setup.outputs.binaries == 'true' + needs: [setup] + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + php-version: ${{ fromJSON(needs.setup.outputs.php-versions) }} + ts: ${{ fromJSON(needs.setup.outputs.ts-modes) }} + arch: ${{ fromJSON(needs.setup.outputs.linux-arches) }} + include: + # arm64 is built on a native ARM runner. A qemu build would be slow + # and would not exercise the hardware users actually run on. + - arch: x86_64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + env: + # Highest glibc symbol version a published .so is allowed to require: + # bookworm's, which is what the build containers below provide, so + # nothing built in them can exceed it. The gate asserts that rather than + # assuming it. + # + # If it ever fires, the build escaped the container -- do not bump the + # number. glibc 2.38 headers redirect the strtol that libmaxminddb calls + # to __isoc23_strtol@GLIBC_2.38, and PHP's -D_GNU_SOURCE enables the + # redirect, so the same sources measure a 2.38 floor on Ubuntu 24.04 and + # 2.33 against bookworm's glibc 2.36. + MAX_GLIBC: "2.36" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + submodules: recursive + persist-credentials: false + + - name: Select the digest-pinned build image + env: + PHP_VERSION: ${{ matrix.php-version }} + TS: ${{ matrix.ts }} + run: | + # Building inside a pinned container instead of on the runner makes + # the glibc floor a deliberate, reviewable choice: raising it means + # editing a digest here, and it cannot drift when GitHub refreshes + # its runner images. Debian bookworm ships glibc 2.36, so a binary + # built here loads on any distro with glibc >= 2.36. + # + # These are multi-arch index digests, so the same digest resolves + # correctly on both the amd64 and the arm64 runner. + case "${PHP_VERSION}-${TS}" in + 8.2-nts) image="php:8.2-cli-bookworm@sha256:2ef1f8d3a9694dab40e45c3404fa18db37d67d3d98c4cf45c51f09bd05ec2672" ;; + 8.3-nts) image="php:8.3-cli-bookworm@sha256:107f022053b2222ffd64f957c2a3b4c724c506301c84638e537f9027ed6468f5" ;; + 8.4-nts) image="php:8.4-cli-bookworm@sha256:8f0c382c6483a202baaad042c9ad51064f050de1093e5e8db990d055dd18534f" ;; + 8.5-nts) image="php:8.5-cli-bookworm@sha256:08ff080781ceb69e81f886513b89daa830aee389abef4bdebc174737b9c0ba81" ;; + 8.2-zts) image="php:8.2-zts-bookworm@sha256:6948daa576e2a3808e52d65697195b5b2ffd599d18d1268e38123ed0b27bc54a" ;; + 8.3-zts) image="php:8.3-zts-bookworm@sha256:bc362fc5d8105ef5494449687f6ad2943b3f01b5c41410fe12ea04d8fed25703" ;; + 8.4-zts) image="php:8.4-zts-bookworm@sha256:1058567505382d754e61e9d3f919e5f59a6f234e29a69eb3c19c353373356f2e" ;; + 8.5-zts) image="php:8.5-zts-bookworm@sha256:f6130a7b4539d29083b7a18aa901c0df2282ed6c9f5c93c50a68eb1f9ce8e9f1" ;; + *) + echo "::error::No pinned image for PHP ${PHP_VERSION} ${TS}." + exit 1 + ;; + esac + echo "IMAGE=$image" >> "$GITHUB_ENV" + + - name: Build in the pinned container + run: | + mkdir -p "$RUNNER_TEMP/out" + docker run --rm \ + -v "$GITHUB_WORKSPACE:/src" \ + -v "$RUNNER_TEMP/out:/out" \ + -e HOME=/tmp \ + "$IMAGE" bash /src/dev-bin/build-ext.sh /src/MaxMind-DB-Reader-php/ext /out + # The container writes as root; hand the results back to the runner. + sudo chown -R "$(id -u):$(id -g)" "$RUNNER_TEMP/out" "$GITHUB_WORKSPACE/MaxMind-DB-Reader-php/ext" + + - name: Gate the built object + run: MaxMind-DB-Reader-php/dev-bin/gate-extension.sh "$RUNNER_TEMP/out/maxminddb.so" + + - name: Load and query in a clean container + env: + MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} + run: | + # A fresh container with nothing mounted but the .so, the check itself + # and a test database: no build tree, no system libmaxminddb, and + # `php -n` so no php.ini can quietly supply anything. If the object + # needs something we did not ship, this is where it shows up. + docker run --rm \ + -v "$RUNNER_TEMP/out/maxminddb.so:/tmp/maxminddb.so:ro" \ + -v "$GITHUB_WORKSPACE/MaxMind-DB-Reader-php/dev-bin/verify-extension.php:/tmp/verify.php:ro" \ + -v "$GITHUB_WORKSPACE/MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb:/tmp/test.mmdb:ro" \ + "$IMAGE" \ + php -n -d extension=/tmp/maxminddb.so /tmp/verify.php /tmp/test.mmdb "$MMDB_VERSION" + + - name: Stage the asset + env: + TAG: ${{ needs.setup.outputs.tag }} + MATRIX_PHP: ${{ matrix.php-version }} + MATRIX_TS: ${{ matrix.ts }} + MATRIX_ARCH: ${{ matrix.arch }} + run: dev-bin/stage-asset.sh "$RUNNER_TEMP/out" "$RUNNER_TEMP/stage" linux glibc + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-asset-linux-${{ matrix.arch }}-php${{ matrix.php-version }}-${{ matrix.ts }} + path: ${{ env.ASSET }} + if-no-files-found: error + + macos: + name: macOS arm64 PHP ${{ matrix.php-version }} ${{ matrix.ts }} + if: needs.setup.outputs.binaries == 'true' + needs: [setup] + # macos-14 is the oldest arm64 image GitHub offers. There is no container + # equivalent of the Linux glibc pin on macOS, so the floor is set by + # MACOSX_DEPLOYMENT_TARGET below and asserted in the gate step. + runs-on: macos-14 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + php-version: ${{ fromJSON(needs.setup.outputs.php-versions) }} + ts: ${{ fromJSON(needs.setup.outputs.ts-modes) }} + env: + # arm64 macOS starts at 11.0, so this is the lowest useful floor. Pinning + # it rather than taking the SDK default keeps the floor from drifting + # when GitHub updates the runner's Xcode. + MACOSX_DEPLOYMENT_TARGET: "11.0" + steps: + - name: Install PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php-version }} + tools: phpize + env: + # setup-php builds thread-safe PHP when asked; nts is its default. + phpts: ${{ matrix.ts }} + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + submodules: recursive + persist-credentials: false + + - name: Build + run: | + if ! command -v autoconf >/dev/null; then + brew install autoconf + fi + dev-bin/build-ext.sh MaxMind-DB-Reader-php/ext "$RUNNER_TEMP/out" + + - name: Gate the built object + run: MaxMind-DB-Reader-php/dev-bin/gate-extension.sh "$RUNNER_TEMP/out/maxminddb.so" + + - name: Load and query in a clean environment + env: + MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} + run: | + # There is no container to isolate into on macOS, so this is `php -n` + # -- no php.ini at all -- with only the built object loaded. + php -n -d extension="$RUNNER_TEMP/out/maxminddb.so" \ + MaxMind-DB-Reader-php/dev-bin/verify-extension.php \ + MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb "$MMDB_VERSION" + + - name: Stage the asset + env: + TAG: ${{ needs.setup.outputs.tag }} + MATRIX_PHP: ${{ matrix.php-version }} + MATRIX_TS: ${{ matrix.ts }} + # macos-14 is arm64-only; the script still cross-checks it against + # what the runner reports. + MATRIX_ARCH: arm64 + # PIE calls the macOS libc bsdlibc. + run: dev-bin/stage-asset.sh "$RUNNER_TEMP/out" "$RUNNER_TEMP/stage" darwin bsdlibc + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-asset-macos-php${{ matrix.php-version }}-${{ matrix.ts }} + path: ${{ env.ASSET }} + if-no-files-found: error + + windows-matrix: + name: Windows build matrix + needs: [setup] + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + count: ${{ steps.count.outputs.count }} + steps: + - name: Get the extension matrix + id: matrix + uses: php/php-windows-builder/extension-matrix@29352c0ef9e8ce65264ea9e287881a6f7758a953 # 1.9.0 + with: + # The C sources and config.w32 live in the main repository; this repo + # is only the PIE packaging shim and keeps them in a submodule, which + # the action's own checkout would not initialise. + extension-url: https://github.com/maxmind/MaxMind-DB-Reader-php + extension-ref: ${{ needs.setup.outputs.ext-ref }} + # Pinned rather than derived from composer.json so the Windows lane + # covers the same PHP versions as every other lane. + php-version-list: ${{ join(fromJSON(needs.setup.outputs.php-versions), ', ') }} + + - name: Count the expected Windows assets + id: count + env: + MATRIX: ${{ steps.matrix.outputs.matrix }} + run: | + count="$(jq 'if type == "array" then length else (.include | length) end' <<<"$MATRIX")" + if [ -z "$count" ] || [ "$count" -lt 1 ]; then + echo "::error::The Windows matrix is empty." + exit 1 + fi + echo "Windows will produce $count assets." + echo "count=$count" >> "$GITHUB_OUTPUT" + + windows: + name: Windows ${{ matrix.arch }} PHP ${{ matrix.php-version }} ${{ matrix.ts }} + needs: [setup, windows-matrix] + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.windows-matrix.outputs.matrix) }} + steps: + # The build action clones extension-url into a build directory of its own + # and checks *this* repository out into the workspace when it finds no + # checkout there -- and its checkout would empty the workspace first, so + # there is no adding one afterwards. Do it here instead, which the action + # detects and skips, because the gate below needs dev-bin and the test + # database. Recursive for the database, which is a submodule of a + # submodule. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + submodules: recursive + persist-credentials: false + + - name: Build the extension + uses: php/php-windows-builder/extension@29352c0ef9e8ce65264ea9e287881a6f7758a953 # 1.9.0 + with: + extension-url: https://github.com/maxmind/MaxMind-DB-Reader-php + extension-ref: ${{ needs.setup.outputs.ext-ref }} + build-directory: ext + php-version: ${{ matrix.php-version }} + arch: ${{ matrix.arch }} + ts: ${{ matrix.ts }} + # Bundled whenever the submodule carries libmaxminddb's sources, the + # same condition the Linux and macOS lanes gate on. The only + # libmaxminddb PHP publishes for Windows is 1.5.0, from January 2021, + # so bundling is what puts Windows on the same libmaxminddb as every + # other platform, and it needs no library fetched at all. Without + # those sources, fall back to fetching that library, which needs + # config.w32 to accept the lib-prefixed name the archive actually + # ships (maxmind/MaxMind-DB-Reader-php#263). + args: ${{ needs.setup.outputs.binaries == 'true' && '--with-maxminddb --with-maxminddb-bundled' || '--with-maxminddb' }} + libs: ${{ needs.setup.outputs.binaries != 'true' && 'maxminddb' || '' }} + # The action stages its own artifact; PIE resolves Windows assets + # through a separate windows-binary mechanism with its own name format, + # which php/php-windows-builder produces by default. + + # What the Linux and macOS lanes get from gate-extension.sh and their + # load-and-query steps. Uploading the artifact is the action's own last + # step, so this necessarily runs after it, but a failure here fails the + # job, and neither verify-assets nor publish runs when a build lane + # failed, so nothing this rejects can reach a release. + # + # The build itself does not notice an extension PHP cannot load: the phpt + # suite skips 001 and 002 on !extension_loaded('maxminddb') and only 003 + # fails, which is how a DLL with no get_module export was published as + # 2 skipped, 1 failed. + - name: Gate the built DLL + shell: pwsh + run: >- + ./dev-bin/gate-extension.ps1 ext MaxMind-DB-Reader-php/dev-bin/verify-extension.php + MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb + ${{ needs.setup.outputs.mmdb-version }} + + verify-assets: + name: Verify the staged assets + # The fan-in for every build lane, and the only place the asset set is + # checked on a run that publishes nothing. Deliberately separate from + # publish: the check is the most valuable thing that job does, and it needs + # no write permission to do it, so it runs on pull requests too. + # + # A skipped need would ordinarily skip this job as well, and the binary + # lanes skip whenever there are no bundled sources to build. Spell out the + # condition instead: a failure anywhere upstream is the only thing that + # should stop the assets being checked. + if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + needs: [setup, source-tarball, source-build, linux, macos, windows-matrix, windows] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + # Only for dev-bin/collect-assets.sh. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + persist-credentials: false + + - name: Download every staged artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + + - name: Collect assets and assert the expected count + env: + TAG: ${{ needs.setup.outputs.tag }} + BINARIES: ${{ needs.setup.outputs.binaries }} + PHP_VERSIONS: ${{ needs.setup.outputs.php-versions }} + TS_MODES: ${{ needs.setup.outputs.ts-modes }} + LINUX_ARCHES: ${{ needs.setup.outputs.linux-arches }} + WINDOWS_COUNT: ${{ needs.windows-matrix.outputs.count }} + run: dev-bin/collect-assets.sh dist assets + + publish: + name: Publish the release + if: needs.setup.outputs.release == 'true' + # The build lanes are reached through verify-assets, which is what asserted + # the set is complete. + needs: [setup, draft-release, windows-matrix, verify-assets] + runs-on: ubuntu-24.04 + permissions: + contents: write # upload assets and un-draft the release + id-token: write # provenance signing + attestations: write # provenance + steps: + # Only for dev-bin/collect-assets.sh. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + persist-credentials: false + + - name: Download every staged artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + + # Re-downloaded and re-collected rather than handed over from + # verify-assets: keeping contents: write out of a job that runs on pull + # requests is worth one repeated download, and this re-asserts the count + # in the same job that uploads. + - name: Collect assets and assert the expected count + env: + TAG: ${{ needs.setup.outputs.tag }} + BINARIES: ${{ needs.setup.outputs.binaries }} + PHP_VERSIONS: ${{ needs.setup.outputs.php-versions }} + TS_MODES: ${{ needs.setup.outputs.ts-modes }} + LINUX_ARCHES: ${{ needs.setup.outputs.linux-arches }} + WINDOWS_COUNT: ${{ needs.windows-matrix.outputs.count }} + run: dev-bin/collect-assets.sh dist assets + + - name: Upload the assets to the draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.setup.outputs.tag }} + run: | + find assets -maxdepth 1 -type f -print0 \ + | xargs -0 gh release upload "$TAG" --clobber + + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: assets/* + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.setup.outputs.tag }} + run: gh release edit "$TAG" --draft=false + + smoke: + name: Smoke test the published binary + # Release runs only. This installs the package from Packagist, so there is + # nothing for it to install on a run that publishes nothing; the staged + # assets are checked by verify-assets instead. + if: needs.setup.outputs.release == 'true' + needs: [setup, publish] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + submodules: recursive + persist-credentials: false + + - name: pie install in a container with no compiler + env: + TAG: ${{ needs.setup.outputs.tag }} + MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} + run: | + # ubuntu:24.04 plus php8.3-cli from apt: glibc 2.39 (comfortably + # above the 2.36 floor), x86_64, NTS, and crucially no compiler and + # no phpize anywhere on the image. If the prebuilt asset is missing + # or malformed, PIE cannot quietly fall back to a source build and + # hide the problem -- the install just fails. + cat > "$RUNNER_TEMP/smoke.sh" <<'SMOKE' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + # php-zip is what PIE unpacks the asset with; mbstring and curl are + # for PIE itself. None of these pull in a compiler or autotools. + apt-get install -y -qq --no-install-recommends \ + ca-certificates curl \ + php8.3-cli php8.3-zip php8.3-mbstring php8.3-curl + for tool in cc gcc g++ make phpize; do + if command -v "$tool" >/dev/null; then + echo "::error::$tool is present; this container is not compiler-free." + exit 1 + fi + done + + curl -fsSL --output /usr/local/bin/pie \ + https://github.com/php/pie/releases/download/1.4.9/pie.phar + chmod +x /usr/local/bin/pie + # Non-Windows prebuilt binaries need PIE >= 1.4.0. + pie --version + + # Packagist can lag a tag push by a minute or two. + version="${TAG#v}" + installed=0 + for _ in $(seq 1 10); do + if pie install "maxmind-db/reader-ext:${version}"; then + installed=1 + break + fi + echo "Retrying in 30s (Packagist may not have $version yet)..." + sleep 30 + done + if [ "$installed" -ne 1 ]; then + echo "::error::pie install never succeeded for $version." + exit 1 + fi + + php --ri maxminddb + php /work/verify.php /work/test.mmdb "$MMDB_VERSION" + SMOKE + + mkdir -p "$RUNNER_TEMP/work" + cp MaxMind-DB-Reader-php/dev-bin/verify-extension.php "$RUNNER_TEMP/work/verify.php" + cp MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb \ + "$RUNNER_TEMP/work/test.mmdb" + + docker run --rm \ + -e TAG \ + -e MMDB_VERSION \ + -v "$RUNNER_TEMP/smoke.sh:/smoke.sh:ro" \ + -v "$RUNNER_TEMP/work:/work:ro" \ + ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 \ + bash /smoke.sh From d3ba4b65cd17cfcfc2014d51eeaa04a9f2961c2a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Aug 2026 20:08:36 +0000 Subject: [PATCH 04/22] Tell PIE to prefer a precompiled binary download-url-method becomes an ordered list: PIE tries pre-packaged-binary first and falls through to pre-packaged-source on any platform a release does not carry a binary for, so nothing regresses for anyone building from source. The array form needs PIE 1.4.0 or later. On 1.3.x a multi-element list collapses to composer-default, which for this package fetches a zipball with no C sources in it -- the extension's sources live in a submodule, and zipballs do not carry those. The single-string form this replaces is deprecated in PIE's own schema. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++++- composer.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0e99d70..01bb45f 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,13 @@ This is the C extension for MaxMind DB Reader, providing significantly faster IP pie install maxmind-db/reader-ext ``` +Precompiled binaries for common platforms are attached to each release, so on +those platforms no compiler or library headers are needed. Elsewhere PIE falls +back to building the extension from source. + ## Prerequisites -The extension requires the libmaxminddb C library: +When building from source, the extension requires the libmaxminddb C library: ### Ubuntu/Debian ```bash diff --git a/composer.json b/composer.json index ce37f63..83593d3 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "priority": 50, "support-zts": true, "support-nts": true, - "download-url-method": "pre-packaged-source", + "download-url-method": ["pre-packaged-binary", "pre-packaged-source"], "configure-options": [ { "name": "with-maxminddb", From 09139ec4365c59f6e9fdfabd3d068d10c1634497 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Aug 2026 20:28:58 +0000 Subject: [PATCH 05/22] Gate the Windows DLL even when the build fails The gate exists to name one defect: a php_maxminddb.dll with no get_module export, which PHP rejects at startup as "Invalid library (maybe not a PHP library)". The only run that ever produced that DLL failed in the build step first, so this step was skipped and never got to say so. What the log showed instead was the phpt suite's "2 skipped, 1 failed", which is the symptom the gate was added to translate. So run it whenever the job was not cancelled. A build can fail and still leave a DLL worth an opinion, and it often fails because of exactly what the gate would find. Doing that naively would make every unrelated build failure report twice, the second time in the gate's voice: with no DLL to find, it would print "Expected exactly one php_maxminddb.dll, found 0" as an ::error:: annotation, as prominent as the real cause and pointing away from it. -BuildFailed tells the script what it cannot work out for itself -- a build that died before linking leaves the same empty tree as one that never ran -- and downgrades that absence to a note. The leniency covers the absent build root and the absent DLL, and nothing else. A DLL that is present is gated exactly as before, which is the point of running at all on a failed build. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 16 +++++++++++++ dev-bin/gate-extension.ps1 | 42 +++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89bce49..53572f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -726,6 +726,7 @@ jobs: persist-credentials: false - name: Build the extension + id: build uses: php/php-windows-builder/extension@29352c0ef9e8ce65264ea9e287881a6f7758a953 # 1.9.0 with: extension-url: https://github.com/maxmind/MaxMind-DB-Reader-php @@ -758,12 +759,27 @@ jobs: # suite skips 001 and 002 on !extension_loaded('maxminddb') and only 003 # fails, which is how a DLL with no get_module export was published as # 2 skipped, 1 failed. + # + # Runs even when the build reports failure, because that is the case it + # was written for and the one case it had never seen: when the object + # collision was reintroduced, the build failed on that same 003 and this + # step was skipped, so the gate sat out the only run that ever produced + # the defect it exists to name. A build can fail and still leave a DLL + # worth an opinion, and "the DLL exports no get_module" is the diagnosis; + # "1 failed" is a symptom. -BuildFailed keeps a build that died before + # linking from being reported as a gate failure -- see the script. + # + # Tested against 'success' rather than 'failure' so that a *skipped* build + # counts too: a checkout that fails leaves the build unrun, which is no + # more the gate's news than a build that crashed. - name: Gate the built DLL + if: ${{ !cancelled() }} shell: pwsh run: >- ./dev-bin/gate-extension.ps1 ext MaxMind-DB-Reader-php/dev-bin/verify-extension.php MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb ${{ needs.setup.outputs.mmdb-version }} + ${{ steps.build.outcome != 'success' && '-BuildFailed' || '' }} verify-assets: name: Verify the staged assets diff --git a/dev-bin/gate-extension.ps1 b/dev-bin/gate-extension.ps1 index cdf0957..7ab66d7 100755 --- a/dev-bin/gate-extension.ps1 +++ b/dev-bin/gate-extension.ps1 @@ -6,12 +6,22 @@ # downloaded -- exists on the Unix lanes, and nothing the bash gate uses exists # here. # -# Usage: gate-extension.ps1 +# Usage: gate-extension.ps1 [-BuildFailed] # # is the directory php/php-windows-builder was told to build in. It # builds under a per-run subdirectory of that, which is where both the DLL and # the php-bin it was built against are found. # +# -BuildFailed says the build step has already failed, which the caller knows +# and this script cannot tell: a build that dies before linking leaves the same +# empty tree as one that never ran. It downgrades exactly two things to a note +# -- an absent build root and an absent DLL -- because the build has already +# reported the real cause and a second ::error:: would only compete with it. +# +# It deliberately stops there. A DLL that is present but wrong is still +# rejected, which is the whole reason to run on a failed build: the failure is +# often a symptom of the defect rather than a reason not to look for it. +# # There is deliberately no Windows analogue of the .so's libmaxminddb NEEDED # check. The libmaxminddb that PHP publishes for Windows is a static # libmaxminddb.lib with no DLL beside it, so the extension carries no @@ -27,7 +37,9 @@ param( [Parameter(Mandatory = $true, Position = 2, HelpMessage = 'Path to a test database')] [string] $Database, [Parameter(Mandatory = $true, Position = 3, HelpMessage = 'Expected MMDB_LIB_VERSION')] - [string] $ExpectedVersion + [string] $ExpectedVersion, + [Parameter(HelpMessage = 'The build step already failed; do not report missing artefacts as errors')] + [switch] $BuildFailed ) $ErrorActionPreference = 'Stop' @@ -41,6 +53,11 @@ function Fail([string] $Message) { throw $Message } +function Skip([string] $Message) { + Write-Host "The build failed $Message, so there is nothing here to gate." + exit 0 +} + function Find-Only([string] $What, $Candidates) { $found = @($Candidates | Where-Object { $null -ne $_ }) if ($found.Count -ne 1) { @@ -49,7 +66,16 @@ function Find-Only([string] $What, $Candidates) { return $found[0].FullName } -foreach ($path in @($BuildRoot, $Verifier, $Database)) { +# $Verifier and $Database come from the checkout that precedes the build, so +# they are missing only if something is wrong with this workflow rather than +# with the build, and that is worth an error even on a failed run. +if (-not (Test-Path -LiteralPath $BuildRoot)) { + if ($BuildFailed) { + Skip "before creating $BuildRoot" + } + Fail "$BuildRoot does not exist." +} +foreach ($path in @($Verifier, $Database)) { if (-not (Test-Path -LiteralPath $path)) { Fail "$path does not exist." } @@ -58,10 +84,18 @@ foreach ($path in @($BuildRoot, $Verifier, $Database)) { # -ErrorAction SilentlyContinue on the walks below because the build tree holds # unpacked PHP and SDK archives whose paths can be too long to enumerate. A path # that could not be walked shows up as a file that was not found, which fails. -$dll = Find-Only 'php_maxminddb.dll' ( +$dlls = @( Get-ChildItem -LiteralPath $BuildRoot -Recurse -File -Filter 'php_maxminddb.dll' ` -ErrorAction SilentlyContinue ) +# The last absence a failed build accounts for, and the narrowest useful place +# to stop: everything below is required of it even then. Once a DLL exists the +# build reached the linker, php-bin was downloaded long before that, and a +# missing one is its own anomaly rather than a consequence of the failure. +if ($dlls.Count -eq 0 -and $BuildFailed) { + Skip 'before producing a DLL' +} +$dll = Find-Only 'php_maxminddb.dll' $dlls # The PHP the extension was built against, and the only one whose version, # thread safety and architecture are guaranteed to match it. $php = Find-Only 'php-bin\php.exe' ( From 1e19b02bd037fdc245f31c8a0fff7a7306dcf50d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:15:38 +0000 Subject: [PATCH 06/22] Delete the unreachable no-bundled-sources fallback setup's second step runs `git submodule update --init ext/libmaxminddb` with no guard, so a submodule tree without that gitlink exits 1 there -- two steps before `binaries=false` was ever computed. The entire documented degradation path was therefore unreachable: the warning, the Linux and macOS lane skips, source-build's bundled=false branch, the Windows system-library fallback, and binary_count=0 in collect-assets.sh, along with about forty lines of comments across two files describing a state that could not occur. Documented-but-dead is the worst of the options, and the submodule now carries the sources unconditionally, so this deletes the fallback rather than repairing it. What replaces it is an explicit precondition with a legible message, since the bare pathspec error git produces explains nothing about what was wanted. That check reads the status separately from the output. `set -e` is suspended inside an `if` condition, so `[ -n "$(git ...)" ]` cannot tell a git failure from an absent entry -- the pattern the shared gate refuses to use, for the same reason. One of the deleted branches, binary_count=0, was also a route by which a release could have shipped the source tarball and no binaries at all: the expectation shrank by exactly the assets the skipped lanes would have produced, so the two faults agreed and the count assertion passed. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 102 +++++++++++++--------------------- dev-bin/collect-assets.sh | 20 +++---- 2 files changed, 47 insertions(+), 75 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53572f3..7d403c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,6 @@ jobs: contents: read outputs: release: ${{ steps.resolve.outputs.release }} - binaries: ${{ steps.resolve.outputs.binaries }} tag: ${{ steps.resolve.outputs.tag }} ext-ref: ${{ steps.resolve.outputs.ext-ref }} php-versions: ${{ steps.resolve.outputs.php-versions }} @@ -62,7 +61,26 @@ jobs: persist-credentials: false - name: Check out the bundled libmaxminddb - run: git -C MaxMind-DB-Reader-php submodule update --init ext/libmaxminddb + run: | + # Every lane compiles libmaxminddb's vendored sources, so their + # presence is a precondition rather than a capability to detect. + # Asserted here because `git submodule update` on a path that is not a + # gitlink fails with a bare pathspec error several steps before + # anything explains what was wanted. + # + # The status is captured separately from the output: `set -e` is + # suspended inside an `if` condition, so `[ -n "$(git ...)" ]` cannot + # tell a git failure from an absent entry, and guessing between those + # two is what this message exists to avoid. + if ! gitlink="$(git -C MaxMind-DB-Reader-php ls-tree HEAD ext/libmaxminddb)"; then + echo "::error::Could not read the submodule's tree; refusing to guess whether it carries libmaxminddb." + exit 1 + fi + if [ -z "$gitlink" ]; then + echo "::error::The submodule does not carry ext/libmaxminddb, which every build lane compiles." + exit 1 + fi + git -C MaxMind-DB-Reader-php submodule update --init ext/libmaxminddb - name: Resolve the version and the build lists id: resolve @@ -115,23 +133,6 @@ jobs: ext_ref="$(git rev-parse HEAD:MaxMind-DB-Reader-php)" fi - # The Linux, macOS and Windows lanes compile libmaxminddb's vendored - # sources, which exist only once ext/libmaxminddb is part of the - # submodule tree. Absence stays fatal for a release -- a release whose - # binaries are silently missing is worse than no release, and - # build-ext.sh says so -- but a pull request should not be red for a - # dependency that has not landed yet, so those two lanes skip instead, - # the same way source-build already skips. Windows cannot skip, - # because it is the only source of the windows-binary assets, so it - # falls back to linking the system library instead. - if [ "$release" = true ] || - [ -n "$(git -C MaxMind-DB-Reader-php ls-tree HEAD ext/libmaxminddb)" ]; then - binaries=true - else - binaries=false - echo "::warning::The submodule carries no libmaxminddb sources; skipping the Linux and macOS lanes." - fi - # Single source of truth for every lane's matrix *and* for the # asset-count assertion, so the two cannot drift. php='["8.2","8.3","8.4","8.5"]' @@ -172,10 +173,9 @@ jobs: exit 1 fi - echo "Building $TAG (release=$release, binaries=$binaries, full matrix=$full, libmaxminddb $mmdb_version)." + echo "Building $TAG (release=$release, full matrix=$full, libmaxminddb $mmdb_version)." { echo "release=$release" - echo "binaries=$binaries" echo "tag=$TAG" echo "ext-ref=$ext_ref" echo "php-versions=$php" @@ -327,20 +327,18 @@ jobs: fail "The tarball contains libmaxminddb's tests or CI configuration." fi - # Once the --with-maxminddb-bundled PR lands, ext/libmaxminddb/ is - # part of the submodule tree and what config.m4 compiles from it must - # end up in the tarball too, along with the license and notice that - # redistributing those sources obliges us to carry. - if grep -q '^libmaxminddb/' <<<"$listing"; then - for member in libmaxminddb/src/maxminddb.c libmaxminddb/include/maxminddb.h \ - libmaxminddb/LICENSE libmaxminddb/NOTICE; do - grep -qx "$member" <<<"$listing" || - fail "libmaxminddb/ is in the tarball but $member is missing." - done - echo "Bundled libmaxminddb sources are present." - else - echo "::warning::No bundled libmaxminddb sources in the submodule yet." - fi + # Everything config.m4 compiles out of ext/libmaxminddb has to reach + # the tarball, along with the license and notice that redistributing + # those sources obliges us to carry. setup has already refused to run + # without them, so absence here is a packaging fault rather than a + # state to tolerate. + for member in libmaxminddb/src/maxminddb.c libmaxminddb/src/data-pool.c \ + libmaxminddb/include/maxminddb.h bundled-include/maxminddb_config.h \ + libmaxminddb/LICENSE libmaxminddb/NOTICE; do + grep -qx "$member" <<<"$listing" || + fail "$member is missing from the tarball; a source build would fail on it." + done + echo "Bundled libmaxminddb sources are present." - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -386,13 +384,8 @@ jobs: path: ${{ runner.temp }}/artifact - name: Extract the tarball into an empty directory - id: extract env: TAG: ${{ needs.setup.outputs.tag }} - # setup sets this when there were bundled sources to build -- always - # on a release. It is the oracle for whether the tarball is *supposed* - # to carry libmaxminddb, which the tarball itself cannot answer. - BINARIES: ${{ needs.setup.outputs.binaries }} run: | tarball="$RUNNER_TEMP/artifact/maxminddb-${TAG}.tgz" if [ ! -f "$tarball" ]; then @@ -415,27 +408,16 @@ jobs: exit 1 fi - # Until ext/libmaxminddb is part of the submodule tree the tarball - # carries no libmaxminddb sources at all and a bundled build is - # impossible, so warn and skip rather than fail -- the same way the - # source-tarball verification warns. - # - # But only when there was nothing to package. Once setup says there - # was -- which it always does on a release -- a tarball missing those - # sources is the exact defect this job exists to catch, and skipping - # on it would turn the test off precisely when it would have failed. - if [ -f "$src/libmaxminddb/src/maxminddb.c" ]; then - echo "bundled=true" >> "$GITHUB_OUTPUT" - elif [ "$BINARIES" = true ]; then + # A tarball missing these is the exact defect this job exists to + # catch, so it fails rather than skipping. Skipping on the condition + # under which the build would break turns the test off precisely when + # it would have fired. + if [ ! -f "$src/libmaxminddb/src/maxminddb.c" ]; then echo "::error::The submodule carries libmaxminddb but the tarball does not; the packaging step dropped it." exit 1 - else - echo "bundled=false" >> "$GITHUB_OUTPUT" - echo "::warning::The tarball carries no libmaxminddb sources; skipping the source build." fi - name: Build from the tarball alone - if: steps.extract.outputs.bundled == 'true' run: | cat > "$RUNNER_TEMP/build.sh" <<'BUILD' set -euo pipefail @@ -468,7 +450,6 @@ jobs: "$IMAGE" bash /build.sh - name: Load and query the object built from the tarball - if: steps.extract.outputs.bundled == 'true' env: MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} run: | @@ -484,7 +465,6 @@ jobs: linux: name: Linux ${{ matrix.arch }} PHP ${{ matrix.php-version }} ${{ matrix.ts }} - if: needs.setup.outputs.binaries == 'true' needs: [setup] runs-on: ${{ matrix.runner }} permissions: @@ -595,7 +575,6 @@ jobs: macos: name: macOS arm64 PHP ${{ matrix.php-version }} ${{ matrix.ts }} - if: needs.setup.outputs.binaries == 'true' needs: [setup] # macos-14 is the oldest arm64 image GitHub offers. There is no container # equivalent of the Linux glibc pin on macOS, so the floor is set by @@ -743,8 +722,7 @@ jobs: # those sources, fall back to fetching that library, which needs # config.w32 to accept the lib-prefixed name the archive actually # ships (maxmind/MaxMind-DB-Reader-php#263). - args: ${{ needs.setup.outputs.binaries == 'true' && '--with-maxminddb --with-maxminddb-bundled' || '--with-maxminddb' }} - libs: ${{ needs.setup.outputs.binaries != 'true' && 'maxminddb' || '' }} + args: --with-maxminddb --with-maxminddb-bundled # The action stages its own artifact; PIE resolves Windows assets # through a separate windows-binary mechanism with its own name format, # which php/php-windows-builder produces by default. @@ -812,7 +790,6 @@ jobs: - name: Collect assets and assert the expected count env: TAG: ${{ needs.setup.outputs.tag }} - BINARIES: ${{ needs.setup.outputs.binaries }} PHP_VERSIONS: ${{ needs.setup.outputs.php-versions }} TS_MODES: ${{ needs.setup.outputs.ts-modes }} LINUX_ARCHES: ${{ needs.setup.outputs.linux-arches }} @@ -849,7 +826,6 @@ jobs: - name: Collect assets and assert the expected count env: TAG: ${{ needs.setup.outputs.tag }} - BINARIES: ${{ needs.setup.outputs.binaries }} PHP_VERSIONS: ${{ needs.setup.outputs.php-versions }} TS_MODES: ${{ needs.setup.outputs.ts-modes }} LINUX_ARCHES: ${{ needs.setup.outputs.linux-arches }} diff --git a/dev-bin/collect-assets.sh b/dev-bin/collect-assets.sh index 2321b31..0299107 100755 --- a/dev-bin/collect-assets.sh +++ b/dev-bin/collect-assets.sh @@ -7,7 +7,7 @@ # # Usage: collect-assets.sh # -# Reads TAG, BINARIES, PHP_VERSIONS, TS_MODES, LINUX_ARCHES and WINDOWS_COUNT. +# Reads TAG, PHP_VERSIONS, TS_MODES, LINUX_ARCHES and WINDOWS_COUNT. set -euo pipefail @@ -35,18 +35,14 @@ collected="$(find "$assets" -maxdepth 1 -type f | wc -l)" php_count="$(jq 'length' <<<"$PHP_VERSIONS")" ts_count="$(jq 'length' <<<"$TS_MODES")" arch_count="$(jq 'length' <<<"$LINUX_ARCHES")" -if [ "$BINARIES" = true ]; then - # Linux (php x ts x arch) + macOS (php x ts). - binary_count=$((php_count * ts_count * arch_count + php_count * ts_count)) -else - # Those two lanes were skipped for want of bundled libmaxminddb sources, so - # expect none of their assets. setup only allows this on a non-release run. - binary_count=0 -fi +# Linux (php x ts x arch) + macOS (php x ts). +binary_count=$((php_count * ts_count * arch_count + php_count * ts_count)) + # 1 source tarball + the binary lanes + whatever the Windows matrix said it -# would produce. Every term comes from setup, which is also what each matrix -# expands, so the expectation cannot drift from what was built -- including when -# setup hands out the reduced pull-request lists or skips the binary lanes. +# would produce. Everything but WINDOWS_COUNT comes from setup, which is also +# what each matrix expands, so the expectation cannot drift from what was built +# -- including when setup hands out the reduced pull-request lists. WINDOWS_COUNT +# comes from windows-matrix, which derives it from the matrix it emits. expected=$((1 + binary_count + WINDOWS_COUNT)) echo "Expecting $expected assets, found $collected:" From 7f7bac74cab6745225c9aa90c08b4e79f7339a02 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:17:19 +0000 Subject: [PATCH 07/22] Find existing drafts the way gh does, not by tag lookup /repos/{owner}/{repo}/releases/tags/{tag} is documented as returning a *published* release, and a draft has no git tag until it is published, so that endpoint 404s for every draft. The reuse branch could therefore never be taken: `jq -r .draft` had no way to return true. The consequence lands on workflow_dispatch, which exists to re-cut assets for an existing tag. An existing draft 404s, the code falls through to `gh release create --draft`, and GitHub does not require draft tag names to be unique -- so a re-cut adds a second draft rather than reusing the first. publish resolves the draft separately when it uploads, so assets could land on one draft while the other was un-drafted. Published releases are still checked against that endpoint, which is exact and needs no pagination, but on the correct grounds: if it returns anything at all the release is published, so there is nothing to inspect. Drafts are found by listing, which is what gh falls back to GraphQL for. More than one draft on a tag is now an error naming the situation rather than something publish resolves arbitrarily. Two other things this job could not do: --notes-from-tag reads the annotation from a local clone, not the API, and this was the only job in the workflow with no checkout -- so it always failed and the notes were always GitHub's generated commit list, announced by a warning. It now checks out the tag. That failure was caught with `if !` and attributed to a lightweight tag, which also swallowed auth failures, rate limits and 5xxs, retrying with generated notes and publishing a release whose notes silently differ from the tag's. The unannotated case is now identified up front with `git cat-file -t`, and nothing else is downgraded. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 71 +++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d403c6..e2825fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,6 +193,16 @@ jobs: permissions: contents: write # create the draft release steps: + # --notes-from-tag reads the annotation out of a local clone rather than + # the API, so without a checkout it always fails and the notes silently + # become GitHub's generated commit list. This job runs only on a release, + # where checkout-ref is the tag, and actions/checkout fetches a tag ref + # into refs/tags, which is what makes the annotation readable below. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + persist-credentials: false + - name: Find or create the draft release env: GH_TOKEN: ${{ github.token }} @@ -201,34 +211,65 @@ jobs: run: | # Assets cannot be added to a published, immutable release, so the # whole run stages onto a draft that publish flips at the very end. + # Finding an existing release therefore has to answer two different + # questions with two different mechanisms. # - # Reusing an existing release therefore has to check that it is still - # a draft, not merely that it exists: `gh release view` succeeds for - # published releases too, and staging onto one would replace bytes - # under a tag users may already have installed. - # + # Published: /releases/tags/{tag} returns published releases only, so + # any success here means published and there is nothing to inspect. # A 404 is the only result that means "not there yet"; every other # failure -- auth, rate limit, a 5xx -- must not be mistaken for it. - if body="$(gh api "repos/${GH_REPO}/releases/tags/${TAG}" 2>"$RUNNER_TEMP/gh.err")"; then - if [ "$(jq -r .draft <<<"$body")" != true ]; then - echo "::error::Release $TAG exists and is already published; refusing to stage assets onto it." - exit 1 - fi - echo "Draft release $TAG already exists; reusing it." - exit 0 + if gh api "repos/${GH_REPO}/releases/tags/${TAG}" >/dev/null 2>"$RUNNER_TEMP/gh.err"; then + echo "::error::Release $TAG exists and is already published; refusing to stage assets onto it." + exit 1 elif ! grep -q 'HTTP 404' "$RUNNER_TEMP/gh.err"; then cat "$RUNNER_TEMP/gh.err" echo "::error::Could not determine whether release $TAG exists; refusing to guess." exit 1 fi + + # Draft: a draft has no git tag until it is published, so that + # endpoint cannot see one and 404s for it too -- which is why gh + # itself resolves drafts through GraphQL. Listing does see them. + # + # This matters because GitHub does not require draft tag names to be + # unique. Left to fall through to `gh release create`, a re-cut would + # add a *second* draft rather than reusing the first, and publish + # would then upload to one while un-drafting whichever the API + # happened to return. + if ! releases="$(gh release list --limit 100 --json tagName,isDraft)"; then + echo "::error::Could not list releases; refusing to guess whether a draft $TAG exists." + exit 1 + fi + drafts="$(jq --arg tag "$TAG" '[.[] | select(.tagName == $tag and .isDraft)] | length' <<<"$releases")" + if [ "$drafts" -gt 1 ]; then + echo "::error::$drafts drafts are tagged $TAG; publish cannot tell which to ship. Delete the extras." + exit 1 + fi + if [ "$drafts" -eq 1 ]; then + echo "Draft release $TAG already exists; reusing it." + exit 0 + fi + # Releases here are titled without the tag's leading "v": v1.13.1 is # titled 1.13.1. Only the title drops it -- the tag itself and every # asset filename keep it, because PIE matches assets on Composer's # pretty version, which is v-prefixed. title="${TAG#v}" - if ! gh release create "$TAG" --draft --title "$title" --notes-from-tag; then - # --notes-from-tag only works for annotated tags. - echo "::warning::--notes-from-tag failed; falling back to generated notes." + + # Decided up front rather than by catching a failed --notes-from-tag: + # a catch-all also swallows a permissions problem, a rate limit and a + # 5xx, and the retry that follows would then publish a release whose + # notes silently differ from the tag's, announced only by a warning. + # An unannotated tag is the one case that genuinely has no notes to + # read, and `cat-file -t` names it exactly. + if ! tag_type="$(git cat-file -t "$TAG" 2>&1)"; then + echo "::error::$TAG is not in the checkout, so its release notes cannot be read: $tag_type" + exit 1 + fi + if [ "$tag_type" = tag ]; then + gh release create "$TAG" --draft --title "$title" --notes-from-tag + else + echo "::warning::$TAG is a lightweight tag and carries no annotation; using generated notes." gh release create "$TAG" --draft --title "$title" --generate-notes fi From 5bbc99fc98d3bc1a01ff308f446671a394387a3b Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:18:01 +0000 Subject: [PATCH 08/22] Give the upload step pipefail and its own draft assertion This is the only pipeline in the workflow without `set -o pipefail`, and the one that ships the release. An inline `run:` gets GitHub's default `bash -e {0}`, so a find that dies partway uploads a subset, xargs still exits 0, and the step goes green. attest-build-provenance then attests the complete local assets/ while `gh release edit --draft=false` publishes a release carrying fewer -- with attestations for assets that are not on it. The hazard is documented at length on the tarball step; it was missing from this one. The "never stage onto a published release" invariant also lived entirely in draft-release. Re-running a single job reuses a dependency's previous result as success without re-executing it, so re-running only publish after a successful publish skips that check and runs `gh release upload --clobber` against a now-published release, replacing bytes under a tag users may already have installed. That is exactly what draft-release says must not happen, so the assertion is now repeated in the job that holds contents: write. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2825fb..43c6074 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -879,6 +879,32 @@ jobs: GH_REPO: ${{ github.repository }} TAG: ${{ needs.setup.outputs.tag }} run: | + # "Never stage onto a published release" is asserted in draft-release, + # which is a different job. Re-running only this one reuses that job's + # previous result as success without re-executing it, so after a + # successful publish the assertion is not in force and `--clobber` + # would replace bytes under a tag users may already have installed. + # Two lines, in the job that actually holds contents: write. + # + # Captured rather than tested inline so a failed call is not reported + # as a published release: both refuse to upload, which is the safe + # direction, but only one of them is true. + if ! is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft)"; then + echo "::error::Could not read the state of release $TAG; refusing to upload." + exit 1 + fi + if [ "$is_draft" != true ]; then + echo "::error::Release $TAG is no longer a draft; refusing to overwrite a published release." + exit 1 + fi + + # The member list is generated on the left of a pipe and an inline + # `run:` gets GitHub's default `bash -e {0}` -- no pipefail -- so a + # find that dies partway would upload a subset while xargs exited 0. + # The step would go green, attestation would then cover the complete + # local assets/ while the release carried less than that, and the next + # step would publish it. + set -o pipefail find assets -maxdepth 1 -type f -print0 \ | xargs -0 gh release upload "$TAG" --clobber From f8317628267a5520ee3118e822ee2b5cc4295813 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:19:46 +0000 Subject: [PATCH 09/22] Validate the shape of counts and versions, not just their magnitude Four values reached code that assumed more about them than anything had established. WINDOWS_COUNT was used in arithmetic. `set -u` does not fire on a set-but-empty variable and arithmetic coerces one to 0, so any path delivering an empty value expected zero Windows assets -- and a release that shipped none would have matched the expectation. It is now required to be a count before it is used as one. The windows-matrix guard could not reject a non-numeric count either. `set -e` is suspended inside an `if` condition, so `[ "$count" -lt 1 ]` on a non-integer exits 2 and makes the whole condition false: the guard passes and the value flows to the arithmetic above. Shape is now checked before magnitude. mmdb_version had only an emptiness check despite being scraped from the submodule's configure.ac, where AC_INIT is free text, and then interpolated unquoted onto a pwsh command line. It gets the same safe-characters regex TAG has, and both it and the -BuildFailed switch now go through env:, like every other job's matrix and needs values -- these were the only two expressions expanded into a run: body in the file. That also removes an ordering fragility: as a positional argument, an empty version shifted -BuildFailed into the mandatory $ExpectedVersion slot. TAG's regex permitted a trailing separator, so a truncated GITHUB_SHA yielded "v0.0.0-" and passed. Harmless in a filename, but this guard is otherwise exact. Verified the pwsh call passes a version containing shell metacharacters through as one inert argument, sets the switch from both 'true' and 'false', and now fails on the mandatory parameter when the version is empty instead of silently shifting. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 36 +++++++++++++++++++++++++++++------ dev-bin/collect-assets.sh | 10 ++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43c6074..bef7a78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,9 @@ jobs: # The tag is interpolated into asset filenames, so constrain it to # characters that are safe there before anything else uses it. The # stand-in version goes through this too rather than around it. - if [[ ! "$TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + # The trailing class excludes the separators: a truncated SHA would + # otherwise yield "v0.0.0-" and pass. + if [[ ! "$TAG" =~ ^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?$ ]]; then echo "::error::Refusing to build: tag '$TAG' is not a safe filename component." exit 1 fi @@ -168,8 +170,12 @@ jobs: # that never reached the compiler as well as a stale bump. mmdb_version="$(sed -n 's/^AC_INIT(\[libmaxminddb\], \[\([^]]*\)\].*/\1/p' \ MaxMind-DB-Reader-php/ext/libmaxminddb/configure.ac)" - if [ -z "$mmdb_version" ]; then - echo "::error::Could not read the libmaxminddb version from the submodule." + # Scraped out of the submodule's configure.ac and then passed to a + # build and a gate, so it gets the same treatment as TAG rather than + # only an emptiness check: a pull request can move the submodule + # pointer, and AC_INIT is free text until something constrains it. + if [[ ! "$mmdb_version" =~ ^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?$ ]]; then + echo "::error::Could not read a usable libmaxminddb version from the submodule; got '$mmdb_version'." exit 1 fi @@ -715,7 +721,18 @@ jobs: MATRIX: ${{ steps.matrix.outputs.matrix }} run: | count="$(jq 'if type == "array" then length else (.include | length) end' <<<"$MATRIX")" - if [ -z "$count" ] || [ "$count" -lt 1 ]; then + # Shape before magnitude: `set -e` is suspended inside an `if`, so + # `[ "$count" -lt 1 ]` on a non-integer exits 2 and makes the whole + # condition false -- the guard passes and collect-assets.sh then + # coerces the value to 0, silently dropping the Windows legs from the + # expected asset count. + case "$count" in + '' | *[!0-9]*) + echo "::error::Could not count the Windows matrix; jq returned '$count'." + exit 1 + ;; + esac + if [ "$count" -lt 1 ]; then echo "::error::The Windows matrix is empty." exit 1 fi @@ -794,11 +811,18 @@ jobs: - name: Gate the built DLL if: ${{ !cancelled() }} shell: pwsh + env: + # Routed through env: like every other job's matrix and needs values. + # Interpolating them into the body put the only two unvalidated + # expansions in the file on a pwsh command line, and made the switch + # positional -- an empty version would have shifted -BuildFailed into + # the mandatory $ExpectedVersion slot. + MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} + BUILD_FAILED: ${{ steps.build.outcome != 'success' }} run: >- ./dev-bin/gate-extension.ps1 ext MaxMind-DB-Reader-php/dev-bin/verify-extension.php MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb - ${{ needs.setup.outputs.mmdb-version }} - ${{ steps.build.outcome != 'success' && '-BuildFailed' || '' }} + $env:MMDB_VERSION -BuildFailed:($env:BUILD_FAILED -eq 'true') verify-assets: name: Verify the staged assets diff --git a/dev-bin/collect-assets.sh b/dev-bin/collect-assets.sh index 0299107..30a2b09 100755 --- a/dev-bin/collect-assets.sh +++ b/dev-bin/collect-assets.sh @@ -32,6 +32,16 @@ collected="$(find "$assets" -maxdepth 1 -type f | wc -l)" [ "$staged" -eq "$collected" ] || fail "Two artifacts contain the same filename ($staged staged, $collected collected)." +# set -u does not fire on a set-but-empty variable, and arithmetic coerces one +# to 0 -- so an empty WINDOWS_COUNT would quietly mean "expect no Windows +# assets", and a release missing all of them would match the expectation and +# ship. Require a count before using it as one. +case "${WINDOWS_COUNT:-}" in +'' | *[!0-9]*) + fail "WINDOWS_COUNT is '${WINDOWS_COUNT:-}', which is not a count." + ;; +esac + php_count="$(jq 'length' <<<"$PHP_VERSIONS")" ts_count="$(jq 'length' <<<"$TS_MODES")" arch_count="$(jq 'length' <<<"$LINUX_ARCHES")" From c1244be3e4838064574a97732a67cad840a79044 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:20:45 +0000 Subject: [PATCH 10/22] Build Windows from the submodule pointer on releases too The Windows lane clones maxmind/MaxMind-DB-Reader-php, a different repository from this shim, so it takes a ref there rather than a checkout here. On a release that ref was the tag, while Linux, macOS and the source tarball all built this repository's submodule pointer -- and nothing compared the two. They agree only if the releaser tagged upstream at exactly the commit the pointer names. Nothing enforces that, and the MMDB_VERSION assertion cannot catch a mismatch: it compares libmaxminddb's PACKAGE_VERSION, not the extension's own C sources. The result would be Windows assets built from different code to every other platform, published under one tag -- the same class of defect checkout-ref was added to fix on the other lanes. Rather than assert the two are equal, use the one that is always right. The submodule pointer is this repository's own statement of what the release contains, and the dry-run path has been passing a bare commit to php/php-windows-builder on every run, so a SHA is already proven to work as extension-ref. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bef7a78..b71a2e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,15 +125,23 @@ jobs: exit 1 fi - # The Windows lane builds maxmind/MaxMind-DB-Reader-php directly - # rather than this repository's checkout of it, and no tag of that - # name exists there on a dry run, so point it at the exact commit the - # submodule -- and therefore every other lane -- builds. - if [ "$release" = true ]; then - ext_ref="$TAG" - else - ext_ref="$(git rev-parse HEAD:MaxMind-DB-Reader-php)" - fi + # The Windows lane builds maxmind/MaxMind-DB-Reader-php directly -- + # a different repository from this packaging shim -- so it needs a ref + # there rather than a checkout here. The submodule pointer is that + # ref on every run, including a release. + # + # It used to be the tag on a release, which meant the Windows assets + # came from upstream's $TAG while Linux, macOS and the tarball came + # from the submodule pointer, with nothing asserting the two were the + # same commit. They agree only if the releaser happened to tag + # upstream at exactly the pointer, and the MMDB_VERSION check cannot + # notice when they do not: it compares libmaxminddb's PACKAGE_VERSION, + # not the extension's own C sources. This is the same "mixture + # published under the tag's name" the checkout_ref below fixed. + # + # The submodule pointer is also the better answer on its own terms -- + # it is what this repository says the release contains. + ext_ref="$(git rev-parse HEAD:MaxMind-DB-Reader-php)" # Single source of truth for every lane's matrix *and* for the # asset-count assertion, so the two cannot drift. From 85589aa47c3cb4753f96faccb4e4d41e9621b199 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:21:26 +0000 Subject: [PATCH 11/22] Probe for pkg-config before trusting its verdict `pkg-config --exists libmaxminddb 2>/dev/null` exits 127 when the tool is not installed, which the `if` reads as "no system libmaxminddb" -- and the redirect discarded the "command not found" that would have said otherwise. So a build image without pkg-config reported the isolation as verified when it was merely unmeasured. That matters more here than anywhere else in the workflow: this is the assertion whose failure invalidates the entire job, in the words of its own error message. The filesystem probe on the next line partly covers it, but partly is the wrong bar for this one. The tool is now required, and with its presence established the 2>/dev/null comes off so a genuine pkg-config error is not swallowed either. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b71a2e9..4773a85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -479,7 +479,17 @@ jobs: # Assert the isolation rather than trusting the base image: a system # libmaxminddb here would let a tarball that is missing sources link # against it and pass anyway. - if pkg-config --exists libmaxminddb 2>/dev/null; then + # pkg-config's own absence exits 127, which the `if` below would read + # as "no system libmaxminddb" -- and the 2>/dev/null this used to + # carry discarded the "command not found" that would have said + # otherwise. This is the assertion whose failure invalidates the whole + # job, so it is the one that should least be able to degrade from + # verified-absent to unmeasured. + if ! command -v pkg-config >/dev/null; then + echo "::error::pkg-config is missing, so a system libmaxminddb cannot be ruled out." + exit 1 + fi + if pkg-config --exists libmaxminddb; then echo "::error::The build image has a system libmaxminddb; this job proves nothing." exit 1 fi From d410dc32a62b56473fd2f345a22939087a618d69 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:22:04 +0000 Subject: [PATCH 12/22] Gate the object built from the source tarball source-build compiles the tarball in an isolated container and load-tests the result, but never ran gate-extension.sh on it. That left the object every platform without a prebuilt binary ends up with as the only one in the pipeline held to no self-containment bar: no RUNPATH check, no exported-MMDB_ check, no dependency allowlist, no glibc floor. The distinction is not academic. config.m4's -fvisibility=hidden and its bundled include path are exactly what a wrong tarball member list would disturb, and the load-and-query check passes whether or not either is in effect. Both inputs the gate needs were already here. /src is mounted from the runner so the build's output is readable outside the container, and the runner has binutils. MAX_GLIBC becomes a job-level constant, the same 2.36 the Linux lane uses, which is correct because this job builds in the same bookworm image -- and it is a separate constant for the same reason it is there: so the measured floor and the documented ceiling can disagree. The tarball build runs the same `./configure --with-maxminddb --with-maxminddb-bundled` as build-ext.sh, so the gate's expectations carry over unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4773a85..97de7e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -422,6 +422,10 @@ jobs: # which does not vary by PHP version, and the Linux lane already builds # every version. Same digest as that lane's 8.4 nts image. IMAGE: php:8.4-cli-bookworm@sha256:8f0c382c6483a202baaad042c9ad51064f050de1093e5e8db990d055dd18534f + # Same container family as the Linux lane, so the same ceiling applies. + # See that job for why this is a separate constant rather than something + # derived from the image. + MAX_GLIBC: "2.36" steps: # Only for the submodule's verify-extension.php and the test database in # step. The build below mounts the extraction directory and nothing else, @@ -514,6 +518,18 @@ jobs: -e HOME=/tmp \ "$IMAGE" bash /build.sh + - name: Gate the object built from the tarball + # The object every platform without a prebuilt binary ends up with was + # the only one in the pipeline held to no self-containment bar: no + # RUNPATH check, no exported-MMDB_ check, no dependency allowlist and no + # glibc floor. The load check below passes regardless of all four, and + # config.m4's -fvisibility=hidden and its bundled include path are + # exactly what a wrong tarball member list would disturb. + # + # /src is mounted from the runner, so the build's output is readable + # here and the gate needs nothing the runner does not already have. + run: MaxMind-DB-Reader-php/dev-bin/gate-extension.sh "$RUNNER_TEMP/src/modules/maxminddb.so" + - name: Load and query the object built from the tarball env: MMDB_VERSION: ${{ needs.setup.outputs.mmdb-version }} From c0f8bf4ae48e9fa390b975f9daa88bf0ae67e83f Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:22:58 +0000 Subject: [PATCH 13/22] Stop the macOS floor assertion checking itself MACOSX_DEPLOYMENT_TARGET was job-level env, so it was both the value the compiler consumed and the ceiling gate-extension.sh compared the measured minos against. That makes the assertion self-referential: it can fire only if the toolchain ignored the variable, and never on a wrong value. Editing it to "15.0" would drop support for macOS 11 through 14 while the gate reported "Measured minimum macOS per slice: 15.0 (documented maximum 15.0)" and passed. It is now declared separately on the build step and on the gate step, so changing what we target does not also move the bar it is measured against. That is the property MAX_GLIBC already has on the Linux lane for the same reason -- the floor comes from the pinned container and the ceiling from its own constant, so the two can disagree. Two declarations of one literal rather than a new variable name, because the gate's contract is that MACOSX_DEPLOYMENT_TARGET *is* the ceiling; renaming it here would need a matching change in the shared gate, which lives in the other repository. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97de7e9..77847a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -658,8 +658,8 @@ jobs: name: macOS arm64 PHP ${{ matrix.php-version }} ${{ matrix.ts }} needs: [setup] # macos-14 is the oldest arm64 image GitHub offers. There is no container - # equivalent of the Linux glibc pin on macOS, so the floor is set by - # MACOSX_DEPLOYMENT_TARGET below and asserted in the gate step. + # equivalent of the Linux glibc pin on macOS, so the floor is set by the + # deployment target the build step exports and asserted in the gate step. runs-on: macos-14 permissions: contents: read @@ -668,11 +668,6 @@ jobs: matrix: php-version: ${{ fromJSON(needs.setup.outputs.php-versions) }} ts: ${{ fromJSON(needs.setup.outputs.ts-modes) }} - env: - # arm64 macOS starts at 11.0, so this is the lowest useful floor. Pinning - # it rather than taking the SDK default keeps the floor from drifting - # when GitHub updates the runner's Xcode. - MACOSX_DEPLOYMENT_TARGET: "11.0" steps: - name: Install PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 @@ -690,6 +685,12 @@ jobs: persist-credentials: false - name: Build + env: + # What the compiler targets. arm64 macOS starts at 11.0, so this is + # the lowest useful floor; pinning it rather than taking the SDK + # default keeps it from drifting when GitHub updates the runner's + # Xcode. + MACOSX_DEPLOYMENT_TARGET: "11.0" run: | if ! command -v autoconf >/dev/null; then brew install autoconf @@ -697,6 +698,22 @@ jobs: dev-bin/build-ext.sh MaxMind-DB-Reader-php/ext "$RUNNER_TEMP/out" - name: Gate the built object + env: + # The ceiling the measured minos is compared against -- deliberately + # a second, independent declaration rather than one job-level value + # shared with the build above. + # + # As job-level env this was both the compiler's input and the gate's + # expectation, so the assertion could only fire if the toolchain had + # ignored the variable; it could never fire on a wrong value. Raising + # the build to "15.0" would have silently dropped support for macOS + # 11 through 14 while the gate reported a pass against its own new + # number. + # + # Declared twice so the two can disagree, which is the same reason + # MAX_GLIBC is separate from the container that sets the Linux floor. + # Change one and the gate says so. + MACOSX_DEPLOYMENT_TARGET: "11.0" run: MaxMind-DB-Reader-php/dev-bin/gate-extension.sh "$RUNNER_TEMP/out/maxminddb.so" - name: Load and query in a clean environment From ee517c24e1d38727d4e2b96dd753c4e86cde862c Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:23:45 +0000 Subject: [PATCH 14/22] Share fail() from dev-bin/lib.sh instead of copying it collect-assets.sh and stage-asset.sh carried byte-identical copies, and release.yml's tarball verification a third inline one. Two of the three are new in this branch, and CLAUDE.md is explicit: re-using a private function means moving it somewhere shared, not duplicating it. The submodule's gate-extension.sh keeps its own copy on purpose. It runs from a checkout of the other repository and both repositories run it, so it has to stand alone; lib.sh says so where someone would go looking. Verified the helper resolves when the scripts are invoked by absolute path and by a relative path from another directory, and that both the rejection and the happy path still behave. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 3 ++- dev-bin/collect-assets.sh | 6 ++---- dev-bin/lib.sh | 17 +++++++++++++++++ dev-bin/stage-asset.sh | 6 ++---- 4 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 dev-bin/lib.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77847a2..26bc4f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -363,7 +363,8 @@ jobs: echo "$listing" echo "$(wc -l <<<"$listing") entries" - fail() { echo "::error::$*"; exit 1; } + # shellcheck source=dev-bin/lib.sh + . dev-bin/lib.sh grep -qx 'config.m4' <<<"$listing" || fail "config.m4 is not at the tarball root; PIE would not find the sources." diff --git a/dev-bin/collect-assets.sh b/dev-bin/collect-assets.sh index 30a2b09..6d6604b 100755 --- a/dev-bin/collect-assets.sh +++ b/dev-bin/collect-assets.sh @@ -14,10 +14,8 @@ set -euo pipefail dist="$1" assets="$2" -fail() { - echo "::error::$*" - exit 1 -} +# shellcheck source=dev-bin/lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" find "$dist" -type f | sort diff --git a/dev-bin/lib.sh b/dev-bin/lib.sh new file mode 100644 index 0000000..80d2e9a --- /dev/null +++ b/dev-bin/lib.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# +# Helpers shared by the scripts in this directory. Source it, do not run it: +# +# . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +# +# The submodule's dev-bin/gate-extension.sh carries its own copy of fail(). It +# is not sourced from here on purpose: it runs from a checkout of a different +# repository, and both repositories run it, so it has to stand alone. + +# ::error:: promotes the message to a GitHub annotation, which is the +# difference between a diagnosis on the run summary and one line somewhere in a +# job log. Exits, so callers can use it as the right-hand side of `||`. +fail() { + echo "::error::$*" + exit 1 +} diff --git a/dev-bin/stage-asset.sh b/dev-bin/stage-asset.sh index 3eda225..221888d 100755 --- a/dev-bin/stage-asset.sh +++ b/dev-bin/stage-asset.sh @@ -14,10 +14,8 @@ stage_dir="$2" os="$3" libc="$4" -fail() { - echo "::error::$*" - exit 1 -} +# shellcheck source=dev-bin/lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" abi="$(cat "$out_dir/php-abi")" ts_suffix="$(cat "$out_dir/ts-suffix")" From 0779632922d1798a3bab613fdeafef063ca4e626 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:24:33 +0000 Subject: [PATCH 15/22] Build the ends of the version range on pull requests, not the middle The reduced matrix built PHP 8.4 alone. For Linux that costs nothing -- the submodule's test-bundled.yml compiles 7.2 through 8.5 on every push, so a version-specific source break is caught upstream. macOS is not covered anywhere. There is no macOS job in the submodule at all, and upstream's Windows job is x64/nts/8.4 only. So every macOS leg for 8.2, 8.3 and 8.5, in both TS modes, first ran on a tag -- and because publish correctly refuses a partial release, a tag then blocked on the legs with the least prior coverage. Six of the eight macOS legs were in that position. Six of the eight pinned build-image digests were never pulled on a pull request either, so a garbage-collected or mistyped digest would pass CI and fail the release. Building 8.2 and 8.5 rather than 8.4 covers both ABI ends for one extra version, which is where a break shows up. source-build's pinned image moves to the 8.5 digest to stay a version the reduced matrix also builds; it is the same digest the Linux lane's 8.5 nts leg uses. Raises the dry-run asset count from 11 to 21. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26bc4f8..fb71767 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,13 +151,24 @@ jobs: if [ "$full" != true ]; then # The knob. A dry run is there to prove the workflow still works, # not to certify every ABI, and 41 assets is a lot to spend on a - # link fix. Dropping PHP versions is the reduction that costs no + # link fix. Dropping PHP versions is the reduction that costs least # coverage of the workflow itself: both TS modes and both Linux # architectures stay, because those are the legs whose steps # differ, and macOS (php x ts) and Windows (php-version-list) both - # still run. 8.4 is the version source-build already pins an image - # for. Widen by adding versions here, or dispatch with full-matrix. - php='["8.4"]' + # still run. + # + # The two kept are the ends of the supported range rather than one + # in the middle. For Linux a single version would do, since the + # submodule's test-bundled.yml compiles 7.2 through 8.5 on every + # push -- but it has no macOS job at all, and upstream's Windows + # job is x64/nts/8.4 only. Whatever this list omits is therefore + # first compiled for those platforms on a tag, where publish + # refuses to ship a partial release and the tag blocks on the leg + # with the least prior coverage. The extremes are where an ABI + # break shows up. + # + # Widen by adding versions here, or dispatch with full-matrix. + php='["8.2","8.5"]' fi # Every other job checks this out. On a release it is the tag, so a @@ -421,8 +432,9 @@ jobs: # # One PHP is enough: what is being tested is the tarball's file list, # which does not vary by PHP version, and the Linux lane already builds - # every version. Same digest as that lane's 8.4 nts image. - IMAGE: php:8.4-cli-bookworm@sha256:8f0c382c6483a202baaad042c9ad51064f050de1093e5e8db990d055dd18534f + # every version. Same digest as that lane's 8.5 nts image, so this is a + # version the reduced pull-request matrix builds too. + IMAGE: php:8.5-cli-bookworm@sha256:08ff080781ceb69e81f886513b89daa830aee389abef4bdebc174737b9c0ba81 # Same container family as the Linux lane, so the same ceiling applies. # See that job for why this is a separate constant rather than something # derived from the image. From 0fb5a422c26a035aa01cd8f4962cc12ca5917ded Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:25:09 +0000 Subject: [PATCH 16/22] Add a dependabot configuration This repository had none, so nothing kept the SHA-pinned actions in release.yml current -- pinning by digest is what makes an action reviewable, and also what makes it go stale silently. github-actions and gitsubmodule, on the same weekly schedule and cooldown the php repo uses. Not composer: this package declares no runtime dependencies. Not the libmaxminddb ignore rule either, since that submodule is nested inside MaxMind-DB-Reader-php rather than here. Three pinned things are deliberately still manual, and the file says so rather than leaving the next reader to discover it: the php:*-bookworm build image digests and the ubuntu:24.04 smoke image, which live in shell inside run: blocks where dependabot's docker ecosystem does not look, and the pie.phar download URL. Editing the build-image digests is how the glibc floor is raised, so their being manual is the intended design -- but nothing will prompt it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f7212c9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +version: 2 +updates: + # Keeps the SHA-pinned `uses:` refs in release.yml current. Pinning by digest + # is what makes an action review-able; it is also what makes it go stale + # without something like this. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '14:00' + cooldown: + default-days: 7 + + # MaxMind-DB-Reader-php is the extension itself: its ext/ sources are what + # every lane compiles and what the release tarball ships. Tracking main here + # is deliberate -- a release bumps this pointer, and the pointer is what + # defines the release's contents. + - package-ecosystem: gitsubmodule + directory: / + schedule: + interval: weekly + day: monday + time: '14:00' + cooldown: + default-days: 7 +# Not covered by any ecosystem above, and so still bumped by hand: +# +# - the digest-pinned php:*-cli-bookworm and php:*-zts-bookworm images, which +# live in a shell `case` in release.yml. Dependabot's docker ecosystem reads +# Dockerfiles and compose files, not image references inside a run: script. +# - the ubuntu:24.04 digest in the smoke job, for the same reason. +# - the pie.phar release URL in the smoke job, which is a plain download. +# +# Raising the Linux glibc floor means editing those digests, which is the point +# of pinning them -- but nothing will remind us, so they are worth a look +# whenever the PHP version list here changes. From 3da38d4e5a19c734f442f8a1d5420a70470b1643 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:27:50 +0000 Subject: [PATCH 17/22] Read the export name column, and report vswhere failures as such Three things in the Windows gate that reported the wrong reason. The get_module check grepped all of dumpbin's output, so it could not tell an unreadable or empty export table from a table that lacks the symbol -- an unmeasurable result arrived as a rejection. The shared bash gate's header states the opposite principle, that an unmeasurable result is fatal as such. It now parses the ordinal/hint/RVA/name table and fails distinctly when no rows are found. That also removes a dependency on output the x86 legs pass by accident. There dumpbin prints "get_module = _get_module" and the old pattern matched the undecorated half; '\bget_module\b' would not match a bare _get_module at all, since _ is a word character, so a change in how dumpbin renders aliases would have falsely rejected every x86 leg. Neither vswhere invocation checked $LASTEXITCODE, so a vswhere that failed and a vswhere that found nothing produced the same message. Both still end at the same Fail -- the outcome was never unsafe -- but the diagnosis now names which happened, which is why $PSNativeCommandUseErrorActionPreference is disabled at the top of this file. Skip now emits ::notice:: rather than a plain log line. It is the only branch in the gate that votes to pass, in a step that then reports success, so silence made "the gate did not run" look like "the gate ran". Verified on Linux with a dumpbin shim, which `Get-Command -CommandType Application` finds on PATH: the x64 and x86 tables are both accepted, a table without get_module is rejected, an empty one is reported as unmeasured, a failing dumpbin is reported as failing, and a php that cannot load the DLL still fails the gate. Co-Authored-By: Claude Opus 5 (1M context) --- dev-bin/gate-extension.ps1 | 51 ++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/dev-bin/gate-extension.ps1 b/dev-bin/gate-extension.ps1 index 7ab66d7..0529894 100755 --- a/dev-bin/gate-extension.ps1 +++ b/dev-bin/gate-extension.ps1 @@ -54,7 +54,10 @@ function Fail([string] $Message) { } function Skip([string] $Message) { - Write-Host "The build failed $Message, so there is nothing here to gate." + # ::notice:: rather than Write-Host: this is the only branch in the gate + # that votes to pass, and the step reports success afterwards. A plain log + # line makes "the gate did not run" indistinguishable from "the gate ran". + Write-Host "::notice::The build failed $Message, so the gate had nothing to check." exit 0 } @@ -123,11 +126,27 @@ if ($null -ne $command) { } elseif ($null -ne ${env:ProgramFiles(x86)}) { $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' if (Test-Path -LiteralPath $vswhere) { + # Both invocations report their exit status. Neither is fatal on its + # own -- the first has a documented fallback and the second ends at the + # same Fail below -- but a vswhere that failed and a vswhere that found + # nothing are different facts, and without this they arrive as the same + # message. + $vswhereTrouble = @() $candidates = @(& $vswhere -latest -prerelease -products * -find '**\dumpbin.exe') + if ($LASTEXITCODE -ne 0) { + # -find needs vswhere 2.6; an older one errors rather than returning + # nothing, which is what this fallback is for. + $vswhereTrouble += "-find exited $LASTEXITCODE" + $candidates = @() + } if ($candidates.Count -eq 0) { - # -find needs vswhere 2.6; fall back to the documented toolset path - # under whichever Visual Studio vswhere reports. + # The documented toolset path under whichever Visual Studio vswhere + # reports. $installed = & $vswhere -latest -prerelease -products * -property installationPath + if ($LASTEXITCODE -ne 0) { + $vswhereTrouble += "-property installationPath exited $LASTEXITCODE" + $installed = $null + } if ($installed) { $glob = Join-Path $installed 'VC\Tools\MSVC\*\bin\Host*\*\dumpbin.exe' $candidates = @(Resolve-Path -Path $glob -ErrorAction SilentlyContinue | @@ -141,7 +160,8 @@ if ($null -ne $command) { } } if ($null -eq $dumpbin) { - Fail 'Found no dumpbin.exe, so the export table cannot be read.' + $why = if ($vswhereTrouble.Count -gt 0) { " (vswhere: $($vswhereTrouble -join '; '))" } else { '' } + Fail "Found no dumpbin.exe, so the export table cannot be read.$why" } Write-Host "Reading exports with $dumpbin" @@ -149,11 +169,28 @@ $exports = & $dumpbin /nologo /exports $dll if ($LASTEXITCODE -ne 0) { Fail "dumpbin could not read $dll (exit $LASTEXITCODE)." } -$export = $exports | Select-String -Pattern '\bget_module\b' | Select-Object -First 1 -if ($null -eq $export) { +# Parsed out of the ordinal/hint/RVA/name table rather than matched against all +# of dumpbin's output. Grepping cannot tell an unreadable or empty export table +# from a table that simply lacks the symbol, so an unmeasurable result would be +# reported as a rejection -- the shared bash gate's header states the opposite +# principle, that an unmeasurable result is fatal as such. +# +# It also removes a dependency on output the x86 legs only pass by accident: +# there dumpbin prints "get_module = _get_module", and '\bget_module\b' matches +# the undecorated half. Against a bare _get_module it would not match at all, +# because _ is a word character and there is no boundary before it. +$exportNames = @( + foreach ($line in $exports) { + if ($line -match '^\s+\d+\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]{8}\s+(\S+)') { $Matches[1] } + } +) +if ($exportNames.Count -eq 0) { + Fail "Read no export table from $dll, so whether it exports get_module is unverified." +} +Write-Host "Exports: $($exportNames -join ', ')" +if ($exportNames -notcontains 'get_module') { Fail "$dll exports no get_module, so PHP would reject it as not a PHP library." } -Write-Host "get_module export: $($export.Line.Trim())" # 2. Load it and query a real database, the equivalent of the Linux lane's # clean-container run. `-n` so no php.ini can supply anything the DLL needs, From cf98cf9b6bd46096e699091e81d607a5aa613b30 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:28:37 +0000 Subject: [PATCH 18/22] Offer the bundled build to source installs, and name the PIE minimum --with-maxminddb-bundled was not declared in configure-options, and PIE passes only the options a package lists. So a source install always linked the system libmaxminddb, while source-build compiles the tarball with --with-maxminddb --with-maxminddb-bundled and asserts no system libmaxminddb is present. The configuration real fallback users got was never built from the published tarball anywhere in the pipeline. Declaring it does not change the default -- the option is opt-in in config.m4 -- so a plain `pie install` still links the system library and the existing prerequisites still apply to it. What changes is that the configuration we actually test becomes reachable, and the source archive already ships the sources it needs. The README also had no minimum PIE version, and this is the only user-facing place it can live: the array form of download-url-method needs 1.4.0, and older versions do not degrade uniformly. 1.3.6 and earlier raise a TypeError, 1.3.7 names 1.4.0 in an exception, and 1.3.8 through 1.3.13 silently fall back to composer-default -- which for this package fetches a dist zip with no C sources, because they live in a submodule, so the build fails with nothing indicating why. The single string this replaced worked for all of them. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 18 +++++++++++++++++- composer.json | 4 ++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 01bb45f..f491bc7 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,25 @@ Precompiled binaries for common platforms are attached to each release, so on those platforms no compiler or library headers are needed. Elsewhere PIE falls back to building the extension from source. +**PIE 1.4.0 or later is required.** Earlier versions do not understand a +package that offers both a precompiled binary and a source fallback, and they +do not all fail clearly about it: 1.3.8 through 1.3.13 quietly fetch an archive +that contains no C sources, so the build fails with nothing pointing at the +cause. Check with `pie --version`. + ## Prerequisites -When building from source, the extension requires the libmaxminddb C library: +None on the platforms with a precompiled binary — that is the point of them. + +When building from source, the extension needs libmaxminddb. It can compile the +copy vendored in the source archive, which is what the precompiled binaries are +built from and what CI tests: + +```bash +pie install maxmind-db/reader-ext --with-maxminddb-bundled +``` + +Otherwise it links the libmaxminddb installed on the system: ### Ubuntu/Debian ```bash diff --git a/composer.json b/composer.json index 83593d3..336c55b 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,10 @@ { "name": "with-maxminddb", "description": "Enable MaxMind DB Reader extension support" + }, + { + "name": "with-maxminddb-bundled", + "description": "Build the bundled libmaxminddb sources into the extension instead of linking a system library" } ] } From b2dd98ce8e703960148299c3eb7a6cd402044b13 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 20:30:19 +0000 Subject: [PATCH 19/22] Correct comments that describe something other than the code No behaviour change; these all claimed things that are not so. The Windows gate's rationale was the worst of them, because it was introduced by the commit that added the gate's `if:` and argued from a failure mode that can no longer occur. ext/tests/003-open-basedir.phpt gained a Windows SKIPIF inside the submodule range this branch pulls in, so all three phpt files now skip on Windows and run-tests.php reports 3 skipped, 0 failed -- not "2 skipped, 1 failed". The conclusion survives and gets stronger: the build now reports fully green for a DLL that PHP cannot load, which makes this gate the only signal there is, so it is restated on those grounds. The tarball's "-C .." was described as the repository root. tar runs from MaxMind-DB-Reader-php/ext, so it is the submodule's root -- and this repository has a LICENSE too, so a reader of a comment living here would open the wrong file. setup-php was described as building thread-safe PHP on macOS. It installs prebuilt binaries from a tap; nothing is compiled. Its documented values for that variable are ts and nts rather than zts and nts, which is worth a note, along with the fact that stage-asset.sh catches the silent failure that would cause. The Linux include: entries were described as annotating rows. True, but only because linux-arches always holds both values -- an entry matching no combination adds a leg, and collect-assets.sh models a product, not added rows. Narrowing that list means fixing the arithmetic too. build-ext.sh said config.m4 "silently falls back" without the bundled sources. It does not: it errors twice over, on a missing --with-maxminddb and on missing sources. The guard is still worth keeping because it fires before phpize and as an annotation, which is what the comment now says. Same file: "both release lanes" is three lanes, and Windows does not use this script; and the submodule is pointed at a commit, not a tag. gate-extension.ps1 pointed at a dev-bin/gate-extension.sh that is not in this repository, and justified one of its three absent checks while leaving the other two to be reconstructed. All five the bash gate runs are now listed, present or absent-because. The smoke test's "needs PIE >= 1.4.0" sat above `pie --version`, which asserts nothing; the constraint is satisfied by the pinned 1.4.9 in the download URL, which carried no comment. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 59 ++++++++++++++++++++++++----------- dev-bin/build-ext.sh | 16 ++++++---- dev-bin/gate-extension.ps1 | 34 +++++++++++++++----- 3 files changed, 77 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb71767..3eabbaa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -333,11 +333,12 @@ jobs: # redistribution to carry the NOTICE content it publishes. # # One member deliberately comes from outside ext/: the extension's - # own LICENSE, which sits at the repository root a level up. A source - # distribution has to carry its own Apache-2.0 licence and not only - # its dependency's, so tar appends it with `-C ..` and it lands flat - # at the archive root like everything else. MaxMind-DB-Reader-php - # publishes no NOTICE of its own. + # own LICENSE. tar runs from MaxMind-DB-Reader-php/ext, so the `-C ..` + # below is the *submodule's* root -- MaxMind-DB-Reader-php/LICENSE, + # not this repository's, which also has one. A source distribution has + # to carry its own Apache-2.0 licence and not only its dependency's, + # and it lands flat at the archive root like everything else. + # MaxMind-DB-Reader-php publishes no NOTICE of its own. # # An inline `run:` gets GitHub's default `bash -e {0}` -- no pipefail # -- and the member list is generated on the left of a pipe. Without @@ -572,6 +573,14 @@ jobs: include: # arm64 is built on a native ARM runner. A qemu build would be slow # and would not exercise the hardware users actually run on. + # + # These annotate existing rows rather than adding any, but only + # because linux-arches always holds both values. An include: entry + # whose values match no combination *adds* a leg, so narrowing that + # list to one arch would create a phantom row with no php-version or + # ts -- and collect-assets.sh computes its expectation as a product, + # which does not model added rows. Narrow it and fix the arithmetic + # together. - arch: x86_64 runner: ubuntu-24.04 - arch: arm64 @@ -688,7 +697,14 @@ jobs: php-version: ${{ matrix.php-version }} tools: phpize env: - # setup-php builds thread-safe PHP when asked; nts is its default. + # setup-php installs a prebuilt thread-safe PHP from the shivammathur + # tap when asked; nts is its default. Nothing is compiled here. + # + # setup-php documents this variable's values as ts and nts rather + # than zts and nts, so a "zts" it does not recognise would silently + # yield an NTS build. stage-asset.sh catches that: it compares the + # ZEND_THREAD_SAFE the built PHP reports against the matrix leg and + # refuses to stage on disagreement. phpts: ${{ matrix.ts }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -855,19 +871,20 @@ jobs: # job, and neither verify-assets nor publish runs when a build lane # failed, so nothing this rejects can reach a release. # - # The build itself does not notice an extension PHP cannot load: the phpt - # suite skips 001 and 002 on !extension_loaded('maxminddb') and only 003 - # fails, which is how a DLL with no get_module export was published as - # 2 skipped, 1 failed. + # On Windows this is the *only* signal that the extension loads. All + # three phpt files skip there -- 001 and 002 on + # !extension_loaded('maxminddb'), and 003 on a Windows SKIPIF the + # submodule added because both its paths are POSIX-specific -- so + # run-tests.php reports 3 skipped, 0 failed and exits 0 for a DLL that + # PHP cannot load at all. The submodule's own comment on that SKIPIF says + # as much: it gives up the property that one test fails, which is why the + # Windows job has to gate the DLL explicitly. # - # Runs even when the build reports failure, because that is the case it - # was written for and the one case it had never seen: when the object - # collision was reintroduced, the build failed on that same 003 and this - # step was skipped, so the gate sat out the only run that ever produced - # the defect it exists to name. A build can fail and still leave a DLL - # worth an opinion, and "the DLL exports no get_module" is the diagnosis; - # "1 failed" is a symptom. -BuildFailed keeps a build that died before - # linking from being reported as a gate failure -- see the script. + # That is also why this runs when the build reports failure. A build can + # fail and still leave a DLL worth an opinion, and "the DLL exports no + # get_module" is a diagnosis where a build log is a symptom. + # -BuildFailed keeps a build that died before linking from being reported + # as a gate failure -- see the script. # # Tested against 'success' rather than 'failure' so that a *skipped* build # counts too: a checkout that fails leaves the build unrun, which is no @@ -1054,7 +1071,11 @@ jobs: curl -fsSL --output /usr/local/bin/pie \ https://github.com/php/pie/releases/download/1.4.9/pie.phar chmod +x /usr/local/bin/pie - # Non-Windows prebuilt binaries need PIE >= 1.4.0. + # 1.4.9 because non-Windows prebuilt binaries need PIE >= 1.4.0; the + # constraint is satisfied by that pin, not by the print below, which + # is only for the log. Pinned by release tag rather than by digest, + # unlike every action and image here -- the blast radius is this + # smoke test. pie --version # Packagist can lag a tag push by a minute or two. diff --git a/dev-bin/build-ext.sh b/dev-bin/build-ext.sh index c18b0d5..d0f964f 100755 --- a/dev-bin/build-ext.sh +++ b/dev-bin/build-ext.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # # Build maxminddb.so from the submodule's ext/ sources and record the fields the -# published asset name is derived from. Used by both release lanes: on Linux -# inside a digest-pinned container, on macOS directly on the runner. +# published asset name is derived from. Used by both Unix lanes: on Linux inside +# a digest-pinned container, on macOS directly on the runner. Windows builds +# through php/php-windows-builder and does not run this. # # Usage: build-ext.sh @@ -12,11 +13,14 @@ ext_dir="$1" out_dir="$2" # --with-maxminddb-bundled compiles libmaxminddb's vendored sources into the -# extension so the published .so needs nothing but libc. Without those sources -# ./configure silently falls back to looking for a system library and we would -# ship a binary with a dangling libmaxminddb dependency, so stop here instead. +# extension so the published .so needs nothing but libc. config.m4 does refuse +# this itself -- AC_MSG_ERROR on both a missing --with-maxminddb and missing +# sources -- so this is not the only thing standing between us and a binary with +# a dangling libmaxminddb dependency. It is worth keeping because it fires +# before phpize and says so as a GitHub annotation rather than in autoconf +# output partway down a job log. if [ ! -f "$ext_dir/libmaxminddb/src/maxminddb.c" ]; then - echo "::error::$ext_dir/libmaxminddb is missing. The submodule must point at a tag that supports --with-maxminddb-bundled." + echo "::error::$ext_dir/libmaxminddb is missing. The submodule must point at a commit that supports --with-maxminddb-bundled." exit 1 fi diff --git a/dev-bin/gate-extension.ps1 b/dev-bin/gate-extension.ps1 index 0529894..229e199 100755 --- a/dev-bin/gate-extension.ps1 +++ b/dev-bin/gate-extension.ps1 @@ -1,6 +1,8 @@ #!/usr/bin/env pwsh # -# The Windows half of dev-bin/gate-extension.sh: refuse to publish a +# The Windows half of MaxMind-DB-Reader-php/dev-bin/gate-extension.sh -- in the +# submodule, not beside this file, which has no .sh counterpart: refuse to +# publish a # php_maxminddb.dll that PHP will not load. Written in PowerShell rather than # added to the bash gate because nothing it uses -- dumpbin, the PHP the build # downloaded -- exists on the Unix lanes, and nothing the bash gate uses exists @@ -22,12 +24,30 @@ # rejected, which is the whole reason to run on a failed build: the failure is # often a symptom of the defect rather than a reason not to look for it. # -# There is deliberately no Windows analogue of the .so's libmaxminddb NEEDED -# check. The libmaxminddb that PHP publishes for Windows is a static -# libmaxminddb.lib with no DLL beside it, so the extension carries no -# libmaxminddb import whether it was built from the bundled sources or against -# the fetched library, and an assertion that always holds would tell us nothing -# about which one we built. +# The bash gate runs five checks and this runs two. The asymmetry is deliberate, +# so all five are accounted for here rather than left to be reconstructed: +# +# - get_module is exported -- checked below, and the whole +# reason this file exists. +# - loads and queries a real database -- checked below. +# - no undefined symbols -- not applicable. A DLL cannot +# link with unresolved imports, +# so there is nothing to measure. +# - no libmaxminddb in the imports -- not applicable. The +# libmaxminddb PHP publishes for +# Windows is a static .lib with no +# DLL beside it, so the import is +# absent whether we built the +# bundled sources or linked the +# fetched library. An assertion +# that always holds would say +# nothing about which we built. +# - nothing but get_module is exported -- not checked. MSVC exports only +# what is marked dllexport, so +# this holds by construction -- +# though the export table read +# below would make asserting it +# nearly free. param( [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Directory the extension was built in')] From c6939c7cae40e92f1eb13f32db25bfea3b51e4f5 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Aug 2026 21:38:43 +0000 Subject: [PATCH 20/22] Test the Windows gate, and run it in CI gate-extension.ps1 is 180 lines standing between an unloadable DLL and a published Windows asset, and it had no test. Every caller runs it over an object expected to pass, so it was only ever observed succeeding -- and the one run that produced the defect it exists to name failed in the build step and never reached it. Most of it turns out to be reachable on Linux, which is what makes this cheap. `Get-Command dumpbin.exe -CommandType Application` finds any executable of that name on PATH, and the load check runs whatever php-bin\\php.exe it finds under the build root, so two shims make the export checks and the load check testable. Only real dumpbin output against a real DLL is genuinely Windows-only, and the Windows lane covers that on every run. Fifteen cases. The first is the positive control, which matters more than the rejections: without it, a gate broken into refusing everything satisfies every other case in the file. Then no get_module, an unreadable export table, a failing dumpbin, a php that cannot load the DLL, the -BuildFailed skip matrix, and the group that has to fail *even with* -BuildFailed -- two DLLs, two php-bin copies, a missing verifier. That last group is the asymmetry the previous commit introduced, and the rule most likely to be flattened by a later refactor. Rejections are matched on the message rather than on a non-zero exit, because 126 and 127 are non-zero too and "the gate never ran" must not read as "the gate said no". Passes additionally assert no ::error:: annotation. Confirmed the suite is load-bearing rather than decorative by breaking the gate two ways and watching it go red: removing the get_module membership test, and letting the -BuildFailed leniency reach the php-bin lookup. The second is a real mistake made while writing the previous commit. verify-assets now depends on the new job, so a gate that has stopped working blocks publication instead of waving it through. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 27 ++- dev-bin/test-gate-extension-windows.sh | 235 +++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 1 deletion(-) create mode 100755 dev-bin/test-gate-extension-windows.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3eabbaa..f90df46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -905,6 +905,31 @@ jobs: MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb $env:MMDB_VERSION -BuildFailed:($env:BUILD_FAILED -eq 'true') + gate-tests: + name: Test the Windows gate + # gate-extension.ps1 is the only thing standing between an unloadable DLL + # and a published Windows asset, and until now it was only ever observed + # succeeding -- the run that produced the defect it exists to name never + # reached it. This runs its rejection cases, and its accept case, on every + # pull request. verify-assets depends on it, so a gate that has stopped + # working blocks publication rather than waving it through. + # + # Linux, where pwsh is preinstalled: the script's dumpbin lookup goes + # through PATH and its load check runs whatever php.exe it found, so both + # are shimmable. Real dumpbin output against a real DLL stays the Windows + # lane's job. + needs: [setup] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.setup.outputs.checkout-ref }} + persist-credentials: false + + - run: dev-bin/test-gate-extension-windows.sh + verify-assets: name: Verify the staged assets # The fan-in for every build lane, and the only place the asset set is @@ -917,7 +942,7 @@ jobs: # condition instead: a failure anywhere upstream is the only thing that # should stop the assets being checked. if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} - needs: [setup, source-tarball, source-build, linux, macos, windows-matrix, windows] + needs: [setup, source-tarball, source-build, linux, macos, windows-matrix, windows, gate-tests] runs-on: ubuntu-24.04 permissions: contents: read diff --git a/dev-bin/test-gate-extension-windows.sh b/dev-bin/test-gate-extension-windows.sh new file mode 100755 index 0000000..b7a53cb --- /dev/null +++ b/dev-bin/test-gate-extension-windows.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# +# Assert that gate-extension.ps1 accepts a good DLL and rejects bad ones. +# +# Named for the gate it tests rather than as plain test-gate-extension.sh: the +# bash gate lives in the submodule and has its own test of that name, and this +# directory holds no gate-extension.sh at all, so the unqualified name would +# point at a file that is not here. +# +# Both tests exist for the reason the submodule's states: every other caller +# runs its gate over an object it expects to pass, so the gate is only ever +# observed succeeding, and nothing would notice it breaking into certifying +# anything -- or, just as quietly, into refusing everything, which a suite of +# rejection cases alone cannot tell apart from working correctly. Hence a +# positive control first. +# +# It runs on Linux, where pwsh is preinstalled on GitHub's runners and where +# most of the gate is reachable: +# +# - `Get-Command dumpbin.exe -CommandType Application` finds any executable +# of that name on PATH, so a shim makes the export checks testable. +# - the load check runs whatever php-bin\php.exe it found under the build +# root, so a second shim decides whether the DLL "loads". +# +# Only real dumpbin output against a real DLL is genuinely Windows-only, and +# the Windows lane covers that on every run. +# +# Usage: test-gate-extension-windows.sh + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=dev-bin/lib.sh +. "$here/lib.sh" + +gate="$here/gate-extension.ps1" +[ -f "$gate" ] || fail "$gate is missing." + +# Required, not skipped. A test suite that quietly does nothing when its +# interpreter is absent is the same failure this gate exists to prevent, one +# level up. +command -v pwsh >/dev/null || fail "pwsh is required to test gate-extension.ps1." + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# Fixtures. The DLLs are empty files: nothing below reads their contents, since +# dumpbin is a shim and php is a shim. +: > "$work/verify.php" +: > "$work/db.mmdb" +mkdir -p "$work/empty" +mkdir -p "$work/good/php-bin" && : > "$work/good/php_maxminddb.dll" +mkdir -p "$work/nophp" && : > "$work/nophp/php_maxminddb.dll" +mkdir -p "$work/twodlls/a" "$work/twodlls/b" +: > "$work/twodlls/a/php_maxminddb.dll" +: > "$work/twodlls/b/php_maxminddb.dll" +mkdir -p "$work/twophp/php-bin" "$work/twophp/nested/php-bin" +: > "$work/twophp/php_maxminddb.dll" + +shims="$work/shims" +mkdir -p "$shims" + +# The header dumpbin prints before the export table. Reproduced so the parser +# is exercised against the noise it has to skip -- "0.00 version", "1 ordinal +# base" and the Summary section all sit in the same column shape. +dumpbin_header='Microsoft (R) COFF/PE Dumper Version 14.38.33130.0 +Copyright (C) Microsoft Corporation. All rights reserved. + +Dump of file php_maxminddb.dll + +File Type: DLL + + Section contains the following exports for php_maxminddb.dll + + 00000000 characteristics + FFFFFFFF time date stamp + 0.00 version + 1 ordinal base + 1 number of functions + 1 number of names + + ordinal hint RVA name +' +dumpbin_footer=' + Summary + + 1000 .data + 1000 .rdata' + +set_dumpbin() { # + { + echo '#!/usr/bin/env bash' + echo "cat <<'DUMPBIN_EOF'" + printf '%s%s%s\n' "$dumpbin_header" "$2" "$dumpbin_footer" + echo 'DUMPBIN_EOF' + echo "exit $1" + } > "$shims/dumpbin.exe" + chmod +x "$shims/dumpbin.exe" +} + +set_php() { # + printf '#!/usr/bin/env bash\necho "php shim: $*"\nexit %s\n' "$1" > "$work/php.exe" + chmod +x "$work/php.exe" + cp "$work/php.exe" "$work/good/php-bin/php.exe" + cp "$work/php.exe" "$work/twophp/php-bin/php.exe" + cp "$work/php.exe" "$work/twophp/nested/php-bin/php.exe" +} + +passed=0 +failed=0 + +# Indents a captured run so a failure's output cannot be mistaken for the +# suite's own. +indent() { + while IFS= read -r line; do + printf ' | %s\n' "$line" + done <<<"$1" +} + +check() { # [extra args...] + local desc="$1" want_exit="$2" want="$3" root="$4" + shift 4 + local out status ok=1 + set +e + out="$(PATH="$shims:$PATH" pwsh -NoProfile -File "$gate" \ + "$root" "$work/verify.php" "$work/db.mmdb" 1.13.3 "$@" 2>&1)" + status=$? + set -e + + if [ "$want_exit" = 0 ]; then + [ "$status" -eq 0 ] || ok=0 + # A pass must not carry an error annotation, and a rejection must not + # be mistaken for one: ::error:: is what the workflow surfaces. + ! grep -q '::error::' <<<"$out" || ok=0 + else + [ "$status" -ne 0 ] || ok=0 + fi + # Matched on the message rather than the status alone, because 126 and 127 + # are non-zero too and "the gate never ran" must not read as "the gate said + # no". + grep -qF "$want" <<<"$out" || ok=0 + + if [ "$ok" = 1 ]; then + passed=$((passed + 1)) + printf ' ok %s\n' "$desc" + else + failed=$((failed + 1)) + printf ' FAIL %s\n exit %s, wanted %s, looking for %s\n' \ + "$desc" "$status" "$want_exit" "$want" + indent "$out" + fi +} + +echo "Parsing $gate" +pwsh -NoProfile -Command " + \$errors = \$null + [System.Management.Automation.Language.Parser]::ParseFile('$gate', [ref]\$null, [ref]\$errors) > \$null + if (\$errors.Count) { \$errors | ForEach-Object { \$_.Message }; exit 1 }" || + fail "$gate does not parse." + +echo +echo "The positive control: without it, a gate broken into always failing" +echo "satisfies every rejection below." +set_php 0 +set_dumpbin 0 ' 1 0 00001D50 get_module' +check "a good DLL is accepted" 0 "Exports: get_module" "$work/good" +# x86 prints the undecorated export name beside the decorated internal one. The +# exported name is the first column, which is what PHP looks up. +set_dumpbin 0 ' 1 0 00001A60 get_module = _get_module' +check "an x86 aliased export table is accepted" 0 "Exports: get_module" "$work/good" + +echo +echo "Rejections that need a readable DLL" +set_dumpbin 0 ' 1 0 00001D50 MMDB_open' +check "an export table without get_module" 1 "exports no get_module" "$work/good" +set_dumpbin 0 '' +check "an unmeasurable export table is not a rejection" 1 "Read no export table" "$work/good" +set_dumpbin 1 ' 1 0 00001D50 get_module' +check "dumpbin failing is reported as dumpbin failing" 1 "dumpbin could not read" "$work/good" +set_dumpbin 0 ' 1 0 00001D50 get_module' +set_php 1 +check "a DLL php cannot load" 1 "did not load and query cleanly" "$work/good" +set_php 0 + +echo +echo "-BuildFailed downgrades absent artefacts to a note" +check "no build root" 0 "::notice::" "$work/nope" -BuildFailed +check "no DLL" 0 "::notice::" "$work/empty" -BuildFailed + +echo +echo "...and the same absences stay errors when the build succeeded" +check "no build root" 1 "does not exist" "$work/nope" +check "no DLL" 1 "Expected exactly one php_maxminddb.dll" "$work/empty" + +echo +echo "The leniency stops at the DLL. These must fail even with -BuildFailed," +echo "and this is the asymmetry a refactor would quietly flatten." +check "two DLLs" 1 "Expected exactly one php_maxminddb.dll" "$work/twodlls" -BuildFailed +check "a DLL but no php-bin" 1 "Expected exactly one php-bin" "$work/nophp" -BuildFailed +check "two php-bin copies" 1 "Expected exactly one php-bin" "$work/twophp" -BuildFailed + +echo +echo "A broken call is this workflow's fault, not the build's" +# Called directly rather than through check(), which supplies a verifier that +# exists. -BuildFailed too: the leniency must not extend to the caller's own +# arguments, which come from the checkout and not from the build. +set +e +out="$(PATH="$shims:$PATH" pwsh -NoProfile -File "$gate" \ + "$work/good" "$work/absent.php" "$work/db.mmdb" 1.13.3 -BuildFailed 2>&1)" +status=$? +set -e +if [ "$status" -ne 0 ] && grep -qF "absent.php does not exist" <<<"$out"; then + passed=$((passed + 1)) + echo " ok a missing verifier, even with -BuildFailed" +else + failed=$((failed + 1)) + echo " FAIL a missing verifier was accepted" + indent "$out" +fi + +set +e +out="$(PATH="$shims:$PATH" pwsh -NoProfile -File "$gate" "$work/good" 2>&1)" +status=$? +set -e +if [ "$status" -ne 0 ]; then + passed=$((passed + 1)) + echo " ok too few arguments" +else + failed=$((failed + 1)) + echo " FAIL the gate accepted a call with too few arguments" +fi + +echo +echo "$passed passed, $failed failed." +[ "$failed" -eq 0 ] || fail "gate-extension.ps1 did not behave as expected." From 9b1c71269594fc7267861dc17bc31b26f134c2ad Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 7 Aug 2026 14:05:35 +0000 Subject: [PATCH 21/22] Check os and libc against the machine before naming an asset Every other component of the published filename is cross-checked against what the build or the runner reports: abi and ts_suffix against the PHP that compiled the object, arch against uname -m. os and libc come from the call site and were compared against nothing, so a copy-paste error in release.yml would publish macOS binaries named -linux-glibc. That is caught today, but only incidentally: collect-assets.sh refuses two artifacts with one filename, and the mistake happens to produce a collision because the two lanes' arch strings overlap. A linux/musl lane would end that, and detecting a wrong name is not what a duplicate check is for. os now comes from uname -s. libc cannot, so the pairing is asserted instead -- the set of libcs that can occur on each OS is small and fixed. Nothing distinguishes glibc from musl on Linux, because there is no musl lane; gate-extension.sh's glibc floor measurement only succeeds against glibc, which corroborates the one combination currently built. Co-Authored-By: Claude Opus 5 (1M context) --- dev-bin/stage-asset.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/dev-bin/stage-asset.sh b/dev-bin/stage-asset.sh index 221888d..0cb34aa 100755 --- a/dev-bin/stage-asset.sh +++ b/dev-bin/stage-asset.sh @@ -37,6 +37,30 @@ aarch64 | arm64) arch=arm64 ;; esac [ "$arch" = "$MATRIX_ARCH" ] || fail "Runner reports $arch but the matrix says $MATRIX_ARCH." +# os and libc come from the call site rather than from the matrix, and were the +# only name components never compared against the machine -- so a copy-paste +# error in the workflow would publish macOS binaries named -linux-glibc. Today +# the duplicate-filename guard in collect-assets.sh would catch that, but only +# because the two lanes' arch strings happen to collide; a linux/musl lane would +# end that and it is not the check's job anyway. +case "$(uname -s)" in +Linux) expected_os=linux ;; +Darwin) expected_os=darwin ;; +*) fail "Unsupported system $(uname -s)." ;; +esac +[ "$os" = "$expected_os" ] || + fail "Runner is $(uname -s) but the asset would be named '$os'." + +# libc cannot be read off uname, so this asserts the pairing instead: the set of +# libcs that can occur on each OS is small and fixed. Nothing here distinguishes +# glibc from musl on Linux -- there is no musl lane yet, and gate-extension.sh's +# glibc floor measurement only succeeds against glibc, which corroborates the +# one combination we currently build. +case "$expected_os-$libc" in +linux-glibc | linux-musl | darwin-bsdlibc) ;; +*) fail "'$libc' is not a libc that occurs on $expected_os." ;; +esac + # PIE's pre-packaged-binary method looks for, all lowercased: # php_{ext}-{version}_php{maj.min}-{arch}-{os}-{libc}[-zts].zip # {version} is Composer's pretty version. Packagist reports this package as From ee52f90a2ba12f684eadbd32f8c16ab4d92f30b9 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 7 Aug 2026 17:35:20 +0000 Subject: [PATCH 22/22] Manage pie with mise instead of curling it The smoke job fetched pie.phar from a release URL pinned by tag, in the one workflow where everything else is pinned by digest. It is a dependency like any other, and this repository already has the machinery for that: mise.lock now carries pie's sha256, and mise verifies GitHub's artifact attestations on download. The php repo does the same for composer.phar. mise runs on the runner and never enters the container. Only the phar crosses over, mounted into the directory the job already mounts, so the image stays exactly as bare as it was -- barer, in fact, since the curl binary was only ever installed to fetch pie and is now gone. The container's own PHP still runs the phar, which is the point: pie's platform binaries bundle a PHP of their own, and `pie install` has to target the PHP a user would be installing into. asset_pattern pins the choice to the phar for that reason. The version constraint is now asserted rather than printed. PIE >= 1.4.0 is what non-Windows prebuilt binaries need; `pie --version` reported it and checked nothing. mise.lock is what holds the version, so the assertion is what notices if the lock is ever moved below the floor. Verified it accepts 1.4.0, 1.4.9, 1.10.0 and 2.0.0, rejects 1.3.7 and 1.3.13, and fails on unparseable output. Caching is disabled on mise-action here. It is on by default, and a workflow that publishes release artifacts is the wrong place for a cache anything else can write to -- zizmor flags exactly this. links.yml keeps its cache; it publishes nothing. Nothing bumps pie automatically yet: Dependabot has no mise ecosystem. Renovate's mise manager does understand the github: backend, and the move to Renovate is a separate piece of work. dependabot.yml's list of manually-bumped things is updated to say so, and pie comes off it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 7 +++- .github/workflows/release.yml | 61 ++++++++++++++++++++++++++++------- mise.lock | 43 ++++++++++++++++++++++++ mise.toml | 12 +++++++ 4 files changed, 111 insertions(+), 12 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f7212c9..b38cb6c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,8 +30,13 @@ updates: # live in a shell `case` in release.yml. Dependabot's docker ecosystem reads # Dockerfiles and compose files, not image references inside a run: script. # - the ubuntu:24.04 digest in the smoke job, for the same reason. -# - the pie.phar release URL in the smoke job, which is a plain download. # # Raising the Linux glibc floor means editing those digests, which is the point # of pinning them -- but nothing will remind us, so they are worth a look # whenever the PHP version list here changes. +# +# pie is not on that list: it is a mise tool, pinned and checksummed in +# mise.lock. Dependabot has no mise ecosystem, so nothing bumps it until the +# planned move to Renovate, whose mise manager understands the github: backend +# -- and which could take the image digests above too, through a regex manager, +# which is not something Dependabot can be configured into doing. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f90df46..36e51c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1067,6 +1067,31 @@ jobs: submodules: recursive persist-credentials: false + # pie is a dependency like any other, so mise manages it: mise.lock + # records the sha256 and mise verifies GitHub's artifact attestations on + # download. It runs here on the runner, never inside the container -- + # only the phar crosses over, so the image below stays exactly as bare as + # it was. + - name: Setup mise + uses: jdx/mise-action@6d1e696aa24c1aa1bcc1adea0212707c71ab78a8 # v3.6.1 + with: + install: false + # This action caches its tool directory by default, and a workflow + # that publishes release artifacts is the wrong place for a cache + # anything else can write to. mise.lock's checksum would catch a + # substituted phar, but not needing to rely on that is cheaper than + # re-downloading 7MB. links.yml keeps the cache; it publishes + # nothing. + cache: false + + - name: Install pie + env: + MISE_AUTO_INSTALL: "false" + # The github backend queries the API; the default token keeps this + # off the unauthenticated rate limit on a release run. + GITHUB_TOKEN: ${{ github.token }} + run: mise install "github:php/pie" + - name: pie install in a container with no compiler env: TAG: ${{ needs.setup.outputs.tag }} @@ -1082,9 +1107,11 @@ jobs: export DEBIAN_FRONTEND=noninteractive apt-get update -qq # php-zip is what PIE unpacks the asset with; mbstring and curl are - # for PIE itself. None of these pull in a compiler or autotools. + # for PIE itself. None of these pull in a compiler or autotools, and + # the curl *binary* is deliberately not among them -- it was only ever + # here to fetch pie, which now arrives mounted. apt-get install -y -qq --no-install-recommends \ - ca-certificates curl \ + ca-certificates \ php8.3-cli php8.3-zip php8.3-mbstring php8.3-curl for tool in cc gcc g++ make phpize; do if command -v "$tool" >/dev/null; then @@ -1093,15 +1120,24 @@ jobs: fi done - curl -fsSL --output /usr/local/bin/pie \ - https://github.com/php/pie/releases/download/1.4.9/pie.phar - chmod +x /usr/local/bin/pie - # 1.4.9 because non-Windows prebuilt binaries need PIE >= 1.4.0; the - # constraint is satisfied by that pin, not by the print below, which - # is only for the log. Pinned by release tag rather than by digest, - # unlike every action and image here -- the blast radius is this - # smoke test. - pie --version + # Run the phar with the container's own PHP rather than one of pie's + # platform binaries, which bundle a PHP of their own: `pie install` + # has to target the PHP a user would be installing into, and that is + # the one in here. + pie() { php /work/pie.phar "$@"; } + + # Non-Windows prebuilt binaries need PIE >= 1.4.0. mise.lock is what + # holds the version, so this asserts rather than prints: it is the + # thing that notices if the lock is ever moved below the floor. + pie_version="$(pie --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)" + case "$pie_version" in + '') echo "::error::Could not read a version from pie."; exit 1 ;; + esac + if [ "$(printf '%s\n1.4.0\n' "$pie_version" | sort -V | head -n1)" != "1.4.0" ]; then + echo "::error::PIE $pie_version is below 1.4.0, which prebuilt non-Windows binaries need." + exit 1 + fi + echo "PIE $pie_version" # Packagist can lag a tag push by a minute or two. version="${TAG#v}" @@ -1124,6 +1160,9 @@ jobs: SMOKE mkdir -p "$RUNNER_TEMP/work" + # Copied rather than mounted from its install path: mise leaves the + # phar mode 0711, which a non-root container user could not read. + cp "$(mise which pie.phar)" "$RUNNER_TEMP/work/pie.phar" cp MaxMind-DB-Reader-php/dev-bin/verify-extension.php "$RUNNER_TEMP/work/verify.php" cp MaxMind-DB-Reader-php/tests/data/test-data/GeoIP2-City-Test.mmdb \ "$RUNNER_TEMP/work/test.mmdb" diff --git a/mise.lock b/mise.lock index 156e912..22dd135 100644 --- a/mise.lock +++ b/mise.lock @@ -1,5 +1,48 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html +[[tools."github:php/pie"]] +version = "1.4.9" +backend = "github:php/pie" + +[tools."github:php/pie".options] +asset_pattern = "pie.phar" + +[tools."github:php/pie"."platforms.linux-arm64"] +checksum = "sha256:19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0" +url = "https://github.com/php/pie/releases/download/1.4.9/pie.phar" +url_api = "https://api.github.com/repos/php/pie/releases/assets/483821541" +provenance = "github-attestations" + +[tools."github:php/pie"."platforms.linux-arm64-musl"] +checksum = "sha256:19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0" +url = "https://github.com/php/pie/releases/download/1.4.9/pie.phar" +url_api = "https://api.github.com/repos/php/pie/releases/assets/483821541" +provenance = "github-attestations" + +[tools."github:php/pie"."platforms.linux-x64"] +checksum = "sha256:19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0" +url = "https://github.com/php/pie/releases/download/1.4.9/pie.phar" +url_api = "https://api.github.com/repos/php/pie/releases/assets/483821541" +provenance = "github-attestations" + +[tools."github:php/pie"."platforms.linux-x64-musl"] +checksum = "sha256:19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0" +url = "https://github.com/php/pie/releases/download/1.4.9/pie.phar" +url_api = "https://api.github.com/repos/php/pie/releases/assets/483821541" +provenance = "github-attestations" + +[tools."github:php/pie"."platforms.macos-arm64"] +checksum = "sha256:19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0" +url = "https://github.com/php/pie/releases/download/1.4.9/pie.phar" +url_api = "https://api.github.com/repos/php/pie/releases/assets/483821541" +provenance = "github-attestations" + +[tools."github:php/pie"."platforms.windows-x64"] +checksum = "sha256:19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0" +url = "https://github.com/php/pie/releases/download/1.4.9/pie.phar" +url_api = "https://api.github.com/repos/php/pie/releases/assets/483821541" +provenance = "github-attestations" + [[tools.lychee]] version = "0.23.0" backend = "aqua:lycheeverse/lychee" diff --git a/mise.toml b/mise.toml index f6a0195..dc050fd 100644 --- a/mise.toml +++ b/mise.toml @@ -4,6 +4,18 @@ lockfile = true [tools] lychee = "latest" +# Used by release.yml's smoke job, which runs it inside a container with no +# compiler to prove PIE can install a prebuilt asset. +# +# asset_pattern because that release also ships pie-Linux-X64 and friends, which +# platform autodetection would prefer -- and those are static binaries with a +# PHP of their own. `pie install` has to target the PHP the user is installing +# into, so the phar, run by that PHP, is the only asset that tests the right +# thing. It is also why every platform in mise.lock resolves to one file. +[tools."github:php/pie"] +version = "latest" +asset_pattern = "pie.phar" + [tasks.check-links] description = "Check links with lychee" run = "lychee --no-progress './**/*.md' './*.c'"