STF-1287: Build and publish PIE precompiled binaries on tag - #2
STF-1287: Build and publish PIE precompiled binaries on tag#2claude[bot] wants to merge 22 commits into
Conversation
DO NOT MERGE. Reintroduces the defect 1643998 fixed, by removing the fourth argument -- the object directory -- from the ADD_SOURCES call for the bundled libmaxminddb sources, so that libmaxminddb/src/maxminddb.c and ext/maxminddb.c both resolve to $(BUILD_DIR)\maxminddb.obj under phpize again and the DLL links without get_module(). Here so that the new Windows gate in maxmind/MaxMind-DB-Reader-php-ext#2 can be observed rejecting something. That gate is green in both build modes, but its rejection path has never run, so we know it passes and not that it fails when it should. The gate was written for this exact defect; pointing that branch's submodule at this commit puts the two together. Only the argument is removed. The comment above the call still describes why the argument is required, which is now wrong -- deliberately, because keeping the diff to one line makes it plain that nothing else changed and makes the revert unambiguous. To be reverted, restoring the fourth argument, before this branch is mergeable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Dq18Ws7Bpj9H1eq4sicaz
Undoes b969d83, restoring the fourth argument to the ADD_SOURCES call for the bundled libmaxminddb sources, so the tree matches 1643998 again and this branch is mergeable. b969d83 reintroduced the object-path collision on purpose, so that the new Windows gate in maxmind/MaxMind-DB-Reader-php-ext#2 could be seen rejecting something rather than only passing. The defect reproduced exactly -- all four Windows legs built a php_maxminddb.dll with no get_module() and PHP answered "Invalid library (maybe not a PHP library)" -- which is worth recording as independent confirmation that the fourth argument is what prevents it, and that this repository's own CI cannot see the difference: every check here was green on b969d83, because config.w32 is only read by a Windows build and there is not one here. The gate itself never ran. php/php-windows-builder runs the phpt suite inside its own build step and 003-open-basedir.phpt has no --SKIPIF--, so the step failed before the gate step was reached and the gate was skipped. Details in that pull request; nothing further is needed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Dq18Ws7Bpj9H1eq4sicaz
maxmind/MaxMind-DB-Reader-php-ext#2 grew a dev-bin/gate-extension.sh that checks the same four properties this workflow checks inline: no libmaxminddb in NEEDED, no RUNPATH/RPATH, no undefined MMDB_ symbols, and a measured glibc floor within a documented maximum. Both run over objects built from these sources, so they should hold the same bar, and two copies cannot. They had already drifted. This workflow treats an unmeasurable glibc floor as an error; the extension repository's copy wrapped the whole measuring pipeline in `|| true` and then skipped the assertion when the result came back empty, so a missing objdump certified the binary instead of failing it. Moving the script here and having both callers run it keeps that from happening again -- a check added for the published binaries is a check this job performs too. The limit stays at the call site. This job builds on the runner and measures a 2.38 floor; the extension repository builds inside a pinned bookworm container and holds itself to 2.36. Both are correct for how they build, so the script reads MAX_GLIBC rather than hard-coding either. The env var here is renamed from MAX_GLIBC_VERSION to match. The script also carries a Darwin branch, unreachable from this Linux-only workflow but used by the extension repository's macOS lane. file(1) is informational and nothing asserts on it, so its absence no longer fails the gate; readelf, nm and objdump all come from binutils and remain required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude here, on behalf of Greg. Pushed three fixup commits from a review pass.
Two other gate fixes from the same pass went to maxmind/MaxMind-DB-Reader-php#265 instead, which now carries a shared Not addressed here: |
Both paths in 003-open-basedir.phpt are POSIX absolute paths, and the test has no --SKIPIF--, so it fails on Windows for reasons that have nothing to do with what it is testing. That is what aborted the Windows run in maxmind/MaxMind-DB-Reader-php-ext#2 before the self-containment gate step was reached, leaving the gate's rejection path unexercised there. Skipping it makes the Windows phpt suite reach the end, which the bundled Windows build needs in order to be verified at all. The condition is deliberately only the platform. 001 and 002 both skip when the extension is not loaded, so this is the one test that fails rather than skips when a build produces an extension that cannot load -- the property that made run-tests.php notice the object-collision bug. Adding an extension_loaded() guard here would remove it, so the comment says so. PHP_OS_FAMILY is available from PHP 7.2, which is this extension's floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maxmind/MaxMind-DB-Reader-php-ext#2 grew a dev-bin/gate-extension.sh that checks the same four properties this workflow checks inline: no libmaxminddb in NEEDED, no RUNPATH/RPATH, no undefined MMDB_ symbols, and a measured glibc floor within a documented maximum. Both run over objects built from these sources, so they should hold the same bar, and two copies cannot. They had already drifted. This workflow treats an unmeasurable glibc floor as an error; the extension repository's copy wrapped the whole measuring pipeline in `|| true` and then skipped the assertion when the result came back empty, so a missing objdump certified the binary instead of failing it. Moving the script here and having both callers run it keeps that from happening again -- a check added for the published binaries is a check this job performs too. The limit stays at the call site. This job builds on the runner and measures a 2.38 floor; the extension repository builds inside a pinned bookworm container and holds itself to 2.36. Both are correct for how they build, so the script reads MAX_GLIBC rather than hard-coding either. The env var here is renamed from MAX_GLIBC_VERSION to match. The script also carries a Darwin branch, unreachable from this Linux-only workflow but used by the extension repository's macOS lane. file(1) is informational and nothing asserts on it, so its absence no longer fails the gate; readelf, nm and objdump all come from binutils and remain required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both paths in 003-open-basedir.phpt are POSIX absolute paths, and the test has no --SKIPIF--, so it fails on Windows for reasons that have nothing to do with what it is testing. That is what aborted the Windows run in maxmind/MaxMind-DB-Reader-php-ext#2 before the self-containment gate step was reached, leaving the gate's rejection path unexercised there. Skipping it makes the Windows phpt suite reach the end, which the bundled Windows build needs in order to be verified at all. The condition is deliberately only the platform. 001 and 002 both skip when the extension is not loaded, so this is the one test that fails rather than skips when a build produces an extension that cannot load -- the property that made run-tests.php notice the object-collision bug. Adding an extension_loaded() guard here would remove it, so the comment says so. PHP_OS_FAMILY is available from PHP 7.2, which is this extension's floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both paths in 003-open-basedir.phpt are POSIX absolute paths and the test has no --SKIPIF--, so on Windows it fails for reasons unrelated to what it tests. That is what aborted the Windows run in maxmind/MaxMind-DB-Reader-php-ext#2 before the self-containment gate step was reached, leaving that repository's gate unexercised on the platform it was added for. The condition is deliberately the platform alone. 001 and 002 both skip when the extension is not loaded, which makes this the one test that *fails* rather than skips when a build produces an extension that cannot load -- the property that made run-tests.php notice the Windows object-collision bug. An extension_loaded() guard here would remove it, so the comment says so. PHP_OS_FAMILY is available from PHP 7.2, this extension's floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
90a43e5 to
4a99d64
Compare
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
4a99d64 to
d3ba4b6
Compare
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) <noreply@anthropic.com>
| # 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"]' |
There was a problem hiding this comment.
I wonder if 8.5 would make sense. Maybe that other spot would need to be bumped? Just thinking maybe using the latest in such cases would be desirable.
There was a problem hiding this comment.
Done — the reduced matrix is now ["8.2","8.5"] rather than ["8.4"], and
source-build's pinned image moves to the 8.5 digest so it stays a version the
matrix builds ("that other spot" was exactly right).
Went with both ends rather than just the latest because of a gap this exposed:
the submodule has no macOS job at all, and upstream's Windows job is
x64/nts/8.4 only, so whatever the reduced list omits is first compiled for those
platforms on a tag — where publish refuses a partial release, so the tag then
blocks on the least-tested leg. 8.2 and 8.5 are where an ABI break shows up.
Six of the eight pinned image digests were also never pulled on a pull request,
so a GC'd digest would have passed CI and failed the release. Two of those six
are now exercised.
Dry-run asset count goes 11 → 21.
🤖 Claude, replying on behalf of Greg.
| # 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 |
There was a problem hiding this comment.
I wonder if dependabot will be able to bump this (or if it should).
There was a problem hiding this comment.
Partly. There was no .github/dependabot.yml in this repository at all, so
nothing was being bumped — that's now added, covering github-actions and
gitsubmodule on the same schedule the php repo uses.
It will not bump this line. Dependabot's docker ecosystem reads Dockerfiles
and compose files, not image references sitting in a run: block or, at line 538,
inside a shell case. Same for the ubuntu:24.04 digest in the smoke job and the
pie.phar URL.
Rather than leave that to be rediscovered, the new dependabot.yml ends with a
comment listing the three things it deliberately does not cover and why. Worth
noting that editing those digests is how the glibc floor is raised, so their
being manual is the intended design — but nothing prompts it, which is the real
gap.
🤖 Claude, replying on behalf of Greg.
| exit 1 | ||
| fi | ||
|
|
||
| # Until ext/libmaxminddb is part of the submodule tree the tarball |
There was a problem hiding this comment.
Some of these comments (above too) are phrased expecting the other PR to not be merged yet. Would updating them make sense?
There was a problem hiding this comment.
Fixed, though not the way this suggests — the branch those comments describe is
gone.
The whole binaries=false fallback turned out to be unreachable: setup's
"Check out the bundled libmaxminddb" step runs git submodule update --init ext/libmaxminddb with no guard, so a submodule tree lacking that gitlink exits 1
there, two steps before binaries=false is computed. The warning, the lane
skips, source-build's bundled=false branch, the Windows system-library
fallback and binary_count=0 were all dead, along with about forty lines of
comments across two files.
So it is deleted rather than reworded, and replaced by an explicit precondition
with a legible message. One of those dead branches was also a route by which a
release could ship the tarball and no binaries at all.
🤖 Claude, replying on behalf of Greg.
| # 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" ;; |
There was a problem hiding this comment.
Similar comment to the above regarding dependabot for these here.
There was a problem hiding this comment.
See my reply on line 371 — same answer, and this case is the specific place Dependabot cannot reach.
🤖 Claude, replying on behalf of Greg.
| done | ||
|
|
||
| curl -fsSL --output /usr/local/bin/pie \ | ||
| https://github.com/php/pie/releases/download/1.4.9/pie.phar |
There was a problem hiding this comment.
I wonder if this could be in mise or something.
I guess the apt commands above may need bumping sometimes too.
Somewhat related to other comments, I see we're using 8.3 for those apt packages.
There was a problem hiding this comment.
Three separate things here, and the honest answer differs for each.
pie.phar — still fetched by release tag, not digest. mise would not help:
this runs inside the smoke container, which is deliberately minimal (no
compiler, no phpize) precisely so a missing prebuilt asset cannot be papered over
by a source build. Installing mise there would work against that. What I did fix
is the comment: "Non-Windows prebuilt binaries need PIE >= 1.4.0" sat above pie --version, which asserts nothing — the constraint is actually satisfied by the
hardcoded 1.4.9 in the URL, which carried no comment at all. The note now sits
on the pin.
The apt packages — php8.3-cli is what ubuntu:24.04 ships. Moving to 8.4 or
8.5 means adding the ondrej PPA, which is a third-party repository in the one
container whose whole purpose is to be a clean, minimal target. The PHP version
here is also not really under test: what is under test is whether PIE can resolve
and install a prebuilt asset without a compiler present.
Bumping generally — the smoke image digest and this apt line are both in the
"Dependabot cannot see it" list I added to dependabot.yml.
🤖 Claude, replying on behalf of Greg.
There was a problem hiding this comment.
You're right and my answer was wrong — done in 1fea279.
My objection assumed mise would have to run inside the smoke container. It doesn't. mise runs on the runner; only the phar crosses over, mounted into the directory the job already mounts. The container is unchanged — barer, actually, since the curl binary was only ever installed to fetch pie and is now gone.
[tools."github:php/pie"]
version = "latest"
asset_pattern = "pie.phar"mise.lock now carries the sha256, and mise verified GitHub's artifact attestations on download — same as the php repo's composer.phar entry, which is where I should have looked first.
asset_pattern matters more than it looks: that release also ships pie-Linux-X64 and friends, which platform autodetection prefers, and those are static binaries bundling their own PHP. pie install has to target the PHP a user would install into, so the phar run by the container's PHP is the only asset that tests the right thing.
Two things fell out of it:
pie --versionis now an assertion. PIE ≥ 1.4.0 is a real constraint and that line checked nothing. Verified it accepts 1.4.0/1.4.9/1.10.0/2.0.0 and rejects 1.3.7/1.3.13.- Caching is off on
mise-actionhere. It's on by default and zizmor flags it as cache poisoning in a workflow that publishes release artifacts.links.ymlkeeps its cache; it publishes nothing.
On automation: Dependabot has no mise ecosystem, so nothing bumps pie until the Renovate migration — Renovate's mise manager does understand the github: backend. Worth noting Renovate could also take the container image digests through a regex manager, which is the thing Dependabot cannot be configured into doing at all. I've recorded both in dependabot.yml.
On the PHP version: I checked, and I don't think mise can help here yet. Both backends compile from source — asdf:mise-plugins/asdf-php, and vfox:mise-plugins/vfox-php, whose README says "Compiles from source: Uses official PHP source releases" and lists autoconf/bison/re2c as prerequisites. A compiler is exactly what this container must not contain. I also searched mise's release notes from v2026.7.14 to v2026.8.2 and found no precompiled-PHP change; if you've seen something more recent, point me at it and I'll revisit.
So php8.3-cli stays, and I've documented why: it's ubuntu:24.04's own, anything newer means a third-party apt repository in the one image whose value is being minimal, and the PHP version is incidental to what the job proves.
Caveat worth stating plainly: smoke only runs on a tag, so none of this is exercised by CI going green. That was already true, and this change doesn't alter it.
🤖 Claude, replying on behalf of Greg.
| -e MMDB_VERSION \ | ||
| -v "$RUNNER_TEMP/smoke.sh:/smoke.sh:ro" \ | ||
| -v "$RUNNER_TEMP/work:/work:ro" \ | ||
| ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 \ |
There was a problem hiding this comment.
Similar to above comments re: image tag / dependabot.
There was a problem hiding this comment.
Covered in my reply on line 908 — the image digest is in the manual list in the new dependabot.yml, and the pie.phar comment has moved onto the pin that actually enforces the constraint.
🤖 Claude, replying on behalf of Greg.
| # | ||
| # 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 |
There was a problem hiding this comment.
Critical: this reuse path is unreachable — REST cannot find draft releases by tag.
GET /repos/{owner}/{repo}/releases/tags/{tag} is documented as "Get a published release with the specified tag". Drafts have no git tag until publication. gh's own pkg/cmd/release/shared/fetch.go says it outright: "First use GraphQL to find a draft release by pending tag name, since REST doesn't have this ability."
So jq -r .draft can never return true and the "Draft release $TAG already exists; reusing it." branch below is dead code.
The published-release guard does work, but for a different reason than the comment gives: if this endpoint returns anything at all it is published, so the != true branch correctly refuses.
What breaks is the re-cut workflow — the stated purpose of workflow_dispatch with a tag. An existing draft 404s, so this falls through to gh release create "$TAG" --draft. GitHub does not require draft tag names to be unique, so that likely creates a second draft rather than 422ing. publish then resolves the draft via GraphQL (gh release upload/edit do see drafts) and picks one — so assets can land on one draft while the other gets un-drafted.
Suggest resolving the draft the way gh does (gh release view "$TAG" --json isDraft, or list-and-filter) while keeping the 404-vs-other-failure discrimination, which is the genuinely good part of this block.
I did not verify this end to end, because doing so would mean creating a real draft release on the repo.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Confirmed and fixed. GET /releases/tags/{tag} returns published releases only,
so jq -r .draft could never be true and the reuse branch was dead.
Both questions are now asked with the mechanism that can answer them:
- Published still uses that endpoint, which is exact and needs no pagination
— but on the correct grounds, as you say: if it returns anything at all the
release is published, so there is nothing to inspect. - Drafts are found by listing, which is what
ghfalls back to GraphQL for.
The 404-vs-other-failure discrimination is kept on both.
The re-cut hazard is handled explicitly rather than incidentally: more than one
draft on a tag is now its own error naming the situation, instead of something
publish resolves arbitrarily.
Also fixed here, which your comment on php#266 line 249 found: this was the only
job in the workflow with no checkout, and --notes-from-tag reads the annotation
from a local clone — so it always failed and the notes were always GitHub's
generated commit list. It now checks the tag out.
🤖 Claude, replying on behalf of Greg.
| # 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" |
There was a problem hiding this comment.
Critical: on a release, Windows builds a different commit than every other lane.
ext_ref is passed to php/php-windows-builder, which clones maxmind/MaxMind-DB-Reader-php — a different repository from this shim. On a release that is upstream's tag $TAG, while Linux, macOS and the source tarball all build this repo's submodule pointer. Nothing asserts they are the same commit.
Verified: the submodule currently points at 0b33422, which git describe reports as v1.13.1-102-g0b33422 — an untagged commit, 102 commits past v1.13.1. For the two to agree, someone must tag upstream at exactly the submodule commit.
The MMDB_VERSION check cannot catch this: it compares libmaxminddb's PACKAGE_VERSION, not the extension's own C sources.
The else branch two lines down already does the right thing. Suggest using the submodule SHA on releases too, or asserting git rev-parse HEAD:MaxMind-DB-Reader-php equals upstream's $TAG in setup. As written this is the same "mixture published under the tag's name" class of bug that lines 152-158 claim to have fixed.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Fixed by removing the divergence rather than asserting it away — as you point
out, the else branch was already doing the right thing.
ext_ref is now the submodule pointer on every run, including releases. That is
this repository's own statement of what the release contains, and the dry-run
path has been handing php/php-windows-builder a bare commit on every run, so a
SHA is already proven to work as extension-ref.
Your note that MMDB_VERSION cannot catch this is what decided it: an assertion
comparing the two would have to be right about which one is authoritative, and
the pointer plainly is.
🤖 Claude, replying on behalf of Greg.
| php_count="$(jq 'length' <<<"$PHP_VERSIONS")" | ||
| ts_count="$(jq 'length' <<<"$TS_MODES")" | ||
| arch_count="$(jq 'length' <<<"$LINUX_ARCHES")" | ||
| if [ "$BINARIES" = true ]; then |
There was a problem hiding this comment.
Critical: the "refuse a partial release" assertion can be satisfied by a tarball-only release.
expected is derived from the same needs.setup.outputs.* values that gate whether the binary lanes run at all (release.yml:487, 598). When a lane does not run, the expectation shrinks by exactly the assets it would have produced — the two faults always agree.
Verified by running this script against a dist/ containing only the source tarball:
BINARIES |
result |
|---|---|
'' |
Expecting 1 assets, found 1 → exit 0 |
false |
Expecting 1 assets, found 1 → exit 0 |
true |
Expecting 7 assets, found 1 → exit 1 |
Today the release path is protected solely by the if at release.yml:127 forcing binaries=true. One output rename, one added if: on setup, or a value spelled True turns a skipped lane into a green, published, tarball-only release — verify-assets tolerates skipped needs by design (release.yml:795), and this script lowers its own bar to match.
Suggest asserting the expected asset names rather than a scalar count. Every input needed to generate all 32 Linux/macOS filenames is already in scope here.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
The mechanism you identified is gone, though the name-based assertion is not
done.
BINARIES no longer exists. The whole fallback was unreachable — see my reply on
line 65 — so binary_count is now computed unconditionally and the expectation
can no longer shrink to match a lane that did not run. That closes the specific
"two faults always agree" coupling.
Asserting the expected filenames rather than a count is the stronger fix and it
is not done; deferred deliberately with Greg. Recording it here as still
open, along with the WINDOWS_COUNT sibling below, which is fixed.
🤖 Claude, replying on behalf of Greg.
| # 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 |
There was a problem hiding this comment.
The invariant this comment asserts is never actually checked.
"setup only allows this on a non-release run" is true of release.yml:127 today, but this script is not told whether it is on a release run — RELEASE is not in its environment in either caller (release.yml:812-820, 849-857). So the comment documents an assumption rather than an enforced property, and the branch below is the mechanism by which a release can ship with zero binaries.
Suggest passing RELEASE and making it explicit:
if [ "$RELEASE" = true ] && [ "$BINARIES" != true ]; then
fail "Release run with BINARIES=$BINARIES; the binary lanes did not run."
fi🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Fixed by deletion — RELEASE never needed passing because the branch it guarded is gone. See line 65.
🤖 Claude, replying on behalf of Greg.
| # 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)) |
There was a problem hiding this comment.
Critical: an empty WINDOWS_COUNT silently becomes 0 here, even under set -euo pipefail.
Verified:
$ ( set -euo pipefail; WINDOWS_COUNT=""; binary_count=0; expected=$((1 + binary_count + WINDOWS_COUNT)); echo "expected=$expected" )
expected=1
exit=0
set -u does not fire because the variable is set-but-empty, and arithmetic coerces it to 0. So any path that delivers an empty WINDOWS_COUNT to publish expects zero Windows assets — and if none arrived, the count matches and the release ships without them.
This is a second, independent route to the same outcome as the BINARIES finding above. Suggest requiring a positive integer:
case "$WINDOWS_COUNT" in ''|*[!0-9]*) fail "WINDOWS_COUNT is '$WINDOWS_COUNT', not a count." ;; esac🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Confirmed and fixed. set -u does not fire on set-but-empty and arithmetic
coerces it to 0, exactly as you show.
collect-assets.sh now requires a count before using one:
case "${WINDOWS_COUNT:-}" in
'' | *[!0-9]*) fail "WINDOWS_COUNT is '${WINDOWS_COUNT:-}', which is not a count." ;;
esacVerified against '', null, 1 6, -1 and 16. The upstream guard in
windows-matrix is fixed too — see your comment on line 698, which is the same
bug one step earlier.
🤖 Claude, replying on behalf of Greg.
| php-version: ${{ fromJSON(needs.setup.outputs.php-versions) }} | ||
| ts: ${{ fromJSON(needs.setup.outputs.ts-modes) }} | ||
| arch: ${{ fromJSON(needs.setup.outputs.linux-arches) }} | ||
| include: |
There was a problem hiding this comment.
This include: only annotates rows because linux-arches is always both values.
A matrix include: entry whose values do not match an existing combination adds a leg rather than annotating one. Harmless as written, but collect-assets.sh:37 computes arch_count from linux-arches, which implies that list is meant to be variable. If it were ever narrowed to one arch, the non-matching include entry would create a phantom row with no php-version or ts — and the product arithmetic in collect-assets.sh does not model added rows, so the count would silently disagree.
That slightly weakens the "single source of truth" claim at 135-136.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Fixed as a comment. The include: entries now say they annotate rather than add
only because linux-arches always holds both values, that an entry matching no
combination adds a leg, and that collect-assets.sh models a product rather than
added rows — so narrowing the list means fixing the arithmetic in the same
change.
Left as documentation rather than code because the coupling is real and the
comment is where someone narrowing that list will be looking.
🤖 Claude, replying on behalf of Greg.
| [ "$ts_suffix" = "$expected_suffix" ] || | ||
| fail "Built PHP thread-safety '$ts_suffix' disagrees with matrix '$MATRIX_TS'." | ||
|
|
||
| case "$(uname -m)" in |
There was a problem hiding this comment.
os and libc are the only name components not cross-checked against the machine.
abi, ts_suffix and arch are all verified against what the build and the runner report — which is the good instinct that makes the Windows gap visible. But os and libc come from the call site (linux glibc / darwin bsdlibc) and are never checked against uname -s, so a copy-paste error in the workflow ships macOS binaries named -linux-glibc.
Partly caught today by the duplicate-filename guard in collect-assets.sh:32 — but only because the arch strings happen to collide. A future linux musl lane would sail through. uname -s is one more case.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Correct, and not fixed. Recording it as open.
The reasoning is worth keeping: abi, ts_suffix and arch are all cross-checked
against what the build and the runner report, which is precisely what makes the
os/libc gap visible — and the duplicate-filename guard only masks it today
because the arch strings happen to collide, which a linux musl lane would end.
uname -s is one more case.
🤖 Claude, replying on behalf of Greg.
There was a problem hiding this comment.
Now fixed in cbb7c23 — I'd marked this open, but it was small enough that leaving it wasn't worth it.
os comes from uname -s. libc can't, so the pairing is asserted instead, since the set of libcs per OS is small and fixed:
case "$expected_os-$libc" in
linux-glibc | linux-musl | darwin-bsdlibc) ;;
*) fail "'$libc' is not a libc that occurs on $expected_os." ;;
esacNothing distinguishes glibc from musl on Linux — there's no musl lane, and gate-extension.sh's glibc floor measurement only succeeds against glibc, which corroborates the one combination we actually build. linux-musl is allowed so that adding the lane you mention doesn't require touching this.
Verified all four cases: a darwin name on a Linux runner rejected, linux gnu rejected (a plausible mistake, since gnu is what several toolchains call it), linux glibc and linux musl accepted.
Your framing is what made it worth doing rather than noting — that the duplicate-filename guard catches this today only because the two lanes' arch strings happen to collide is exactly the kind of accidental coverage that disappears without anyone noticing.
🤖 Claude, replying on behalf of Greg.
| # 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 |
There was a problem hiding this comment.
Minor asymmetry: this regex accepts a trailing dash, and mmdb_version gets no regex at all.
^[A-Za-z0-9][A-Za-z0-9._+-]*$ permits a trailing -, so a truncated GITHUB_SHA yields v0.0.0- and passes. Harmless for filenames, noted only because this guard is otherwise careful.
The more substantive half: mmdb_version (lines 168-173) gets only an emptiness check despite being interpolated unquoted into a pwsh command line at 781. Given the care taken over TAG here, closing that gap seems worth it — see my comment on 781.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Both fixed. The regex is now
^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?$ — verified it still accepts
v1.14.0, v1.14.0-beta1, 1.2.3+build and the v0.0.0-pr2-abc1234 stand-in,
and now rejects v0.0.0-.
mmdb_version gets the same regex and moves into env: — see your comment on
line 781.
🤖 Claude, replying on behalf of Greg.
| fi | ||
| done | ||
|
|
||
| curl -fsSL --output /usr/local/bin/pie \ |
There was a problem hiding this comment.
pie.phar is fetched by version tag while everything else is digest-pinned.
Every action here is SHA-pinned and every build image is digest-pinned; this downloads a .phar from a mutable release URL. Blast radius is confined to the smoke test, so this is a consistency nit rather than a risk.
Related: the "Non-Windows prebuilt binaries need PIE >= 1.4.0" comment two lines down sits above pie --version, which prints and asserts nothing. The constraint is actually satisfied by the hardcoded 1.4.9 in this URL, which carries no comment. Suggest moving the note here, or making it a real assertion.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Half fixed. The comment has moved onto the pin that actually enforces the
constraint, since pie --version asserts nothing — that was the more misleading
half.
The .phar is still fetched by release tag. Pinning it by digest is reasonable
and not done; noting your own assessment that the blast radius is confined to the
smoke test, which is why it did not make this pass.
🤖 Claude, replying on behalf of Greg.
|
|
||
| # Packagist can lag a tag push by a minute or two. | ||
| version="${TAG#v}" | ||
| installed=0 |
There was a problem hiding this comment.
This loop cannot distinguish a broken asset from Packagist lag.
A genuinely malformed asset burns five minutes and then reports "pie install never succeeded for $version" — the same message Packagist lag produces. Since this job is post-publication and non-blocking anyway, it is diagnosis quality rather than gating, but "PIE found no matching asset" and "Packagist does not know this version yet" are very different emergencies, and only one of them is an emergency at all.
🤖 Comment by Claude (Claude Code) on behalf of Will.
There was a problem hiding this comment.
Fair, and not fixed. Recording it as open.
Agreed on the framing that this is diagnosis quality rather than gating — but
"PIE found no matching asset" and "Packagist does not know this version yet" being
indistinguishable is worth fixing precisely because only one of them is an
emergency, and this job reports after the release is already public.
🤖 Claude, replying on behalf of Greg.
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) <noreply@anthropic.com>
/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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
--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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This script's half of the extension release is a tag push; everything after that is release.yml in the other repository. That workflow is not on the extension repository's default branch yet -- it lives only on maxmind/MaxMind-DB-Reader-php-ext#2 -- and if this merges first, a tag push triggers nothing while the script prints success and exits 0. There is no recovery from that state either. The tag guard further down then refuses every retry because the tag exists, and the `gh workflow run` it suggests cannot help, because workflow_dispatch also resolves the workflow from the default branch. The ordering was documented in the pull request and enforced nowhere. Checked with the other pre-flight checks, before anything is published. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the review — I've replied to each comment individually. Overview of what changed, and what deliberately did not. 15 new commits, one per concern. No fixups, since the branch has now been reviewed. The findings that could ship something broken
The dead fallback
Coverage
Also
Not done, on purposeDeferred with Greg as a follow-up, all real:
🤖 Claude, on behalf of Greg. Edited: the script was named |
|
Filed ENG-5218 for the container image digests, blocked on ENG-5215 (Renovate for mise dependencies). That covers the three dependabot questions on this PR — the The short version of what's in it: Dependabot cannot do this, and no configuration fixes it. Its Renovate can, via a custom regex manager: one entry, no annotation comments, no change to The issue also notes an existing wart to clear up whenever this is touched: In this PR, 🤖 Claude, on behalf of Greg. |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
1fea279 to
ee52f90
Compare
Requested by Greg Oschwald · Slack thread
Today
pie install maxmind-db/reader-extalways compiles from source, so a userneeds a toolchain and
libmaxminddb-devheaders. After this, PIE downloads aprebuilt
maxminddb.soon Linux (x86_64/arm64) and macOS (arm64) for PHP8.2–8.5, NTS and ZTS, plus Windows — and still falls back to building from
source everywhere else, unchanged.
The one line that enables it is
"download-url-method": ["pre-packaged-binary", "pre-packaged-source"]. PIE tries the methods in order, so an uncoveredplatform misses the binary and falls through to the tarball.
.github/workflows/release.ymlruns onv*tags, on pull requests, and onworkflow_dispatch(with ataginput, to re-cut assets without re-tagging, orwithout one for a dry run). A tag stages everything onto a
draft release — assets cannot be added to a published, immutable release — and
only un-drafts at the end. Each Linux/macOS build is gated before it is staged by
dev-bin/gate-extension.shfrom the submodule, so the objects publishedhere and the ones maxmind/MaxMind-DB-Reader-php tests are held to one
implementation of the same bar: nothing but the C runtime in
NEEDED, noRUNPATH/RPATH, no undefined or exported
MMDB_symbols,get_modulestillexported, no
GLIBC_PRIVATE, a measured glibc floor within the documentedmaximum, and a load-and-query of a real database in a clean container. Windows is gated too: the
php_maxminddb.dllit built must exportget_module, and must load underphp -nand answer a real lookup.The source tarball PIE falls back to is built from an explicit member list: the
extension's own sources and
LICENSE, plus only the libmaxminddb sourcesconfig.m4compiles, the headers it puts on theinclude path, and that library's
LICENSEandNOTICE, which Apache-2.0 §4(d)requires a redistribution to carry. A bare glob would publish the vendored
checkout's gitfile, CI configuration, test suite and nested test-data
submodules — 101 entries where 16 are needed — and the verification step now
asserts that. The layout stays flat, with
config.m4at the archive root,because that is where PIE looks; the extension's
LICENSElives a level aboveext/, sotarappends it with-C ..and it lands flat there too.Every binary lane builds from the submodule tree, so a
source-buildjobbuilds the staged tarball itself: extracted into an empty directory, in a
container with only that directory mounted and no system libmaxminddb to stand
in for a source the tarball failed to ship, then loaded for a real lookup.
Without it, a libmaxminddb reshuffle would break source-installing users while
every other gate stayed green. It runs alongside the binary lanes, and
publishwaits for it.publishasserts the exact expected asset count and refuses to publish if itdoes not match. That assertion is deliberate: the Python repo once shipped a
release with an sdist and zero wheels because an artifact glob silently matched
nothing.
Everything except the release itself also runs on pull requests, so a change
to this workflow is tested by the pull request that makes it rather than by the
next tag. A pull-request run builds and gates every lane, builds the tarball and
builds from the tarball, and asserts the asset count in a
verify-assetsjob— running the same
dev-bin/collect-assets.shthatpublishruns immediatelybefore uploading, so the assertion cannot rot into release-only code. What it
skips is everything that writes:
draft-release, the upload/attest/un-draftsteps, and
smoke, which installs from Packagist and so would have nothing toinstall.
contents: writeandid-token: writebelong topublishanddraft-releasealone and neither is reachable without a tag, which also means afork's pull request never needs a permission it cannot be given.
There is no tag on a pull request, so
setupstands inv0.0.0-pr<number>-<short sha>. That goes through the same sanitisation regex areal tag does rather than around it, and it keeps the leading
v, because PIEmatches assets on Composer's v-prefixed pretty version and the naming code must
have one rule and not two. The Windows lane clones
MaxMind-DB-Reader-phpbyref and no tag of that name exists there, so on a tagless run it builds the
commit the submodule points at — which is what every other lane builds, and is
also what decides whether it gets the bundled flag.
Pull requests build a reduced matrix: one PHP version instead of four, so 11
assets instead of 41. Both TS modes, both Linux architectures, macOS and Windows
all still run, because those are the legs whose steps actually differ; only PHP
versions are dropped, which costs no coverage of the workflow itself. The knob is
a single assignment in
setup, immediately below the full lists it narrows —widen it by adding versions there. A tag always builds the full matrix, since
publishrefuses to ship a partial release, andworkflow_dispatchtakes afull-matrixinput to ask for the whole thing on a branch.setupis still theonly place both the matrices and the count assertion read from, so a reduced run
asserts a reduced count. Superseded pull-request runs are cancelled; a release in
flight is not.
The build, gate, load-and-query and staging shell lives in
dev-bin/ratherthan duplicated per-OS in the workflow, so there is one copy of each and it can
be run outside Actions. Windows shares neither the tools nor the shell with the
Unix lanes, so its gate is a sibling script there,
gate-extension.ps1, ratherthan a branch inside the bash one.
Linux binaries are built in digest-pinned
php:{version}-{cli,zts}-bookwormcontainers rather than on the runner, so the glibc floor (2.36) is a reviewable
choice that cannot drift with runner images. The 2.38 floor reported for the
bundled sources is what building outside those containers costs: glibc 2.38
headers redirect the
strtollibmaxminddb calls to__isoc23_strtol@GLIBC_2.38and PHP compiles with-D_GNU_SOURCE, whichenables the redirect. Inside bookworm that symbol does not exist, and the gate
fails closed if a build ever escapes. arm64 comes from a native
ubuntu-24.04-armrunner, not qemu. macOS has no container equivalent, so thefloor there is a pinned
MACOSX_DEPLOYMENT_TARGET, asserted againstLC_BUILD_VERSION.Dependencies, both in
maxmind/MaxMind-DB-Reader-php:--with-maxminddb-bundledconfigure flag (PR inflight, branch
stf-1287-…), which compiles libmaxminddb's vendored sourcesinto the extension so the
.soneeds only libc. Until the submodule points ata tag that has it, a tag run fails fast with an explicit message and a
pull-request run skips the Linux and macOS lanes with a warning —
setuplooks for
ext/libmaxminddbin the submodule's tree, and consults that onlywhen there is no tag. A pull request should not be red for a dependency it has
nothing to do with; a release whose binaries are silently missing is worse than
no release. The expected asset count follows the same flag, so a run with the
lanes skipped still asserts the assets it did expect.
the only source of the windows-binary assets. With the vendored sources present
it passes
--with-maxminddb-bundledand asks for no libraries at all; withoutthem it falls back to fetching the libmaxminddb PHP publishes, which is 1.5.0
and needs #263 so that
config.w32accepts thelib-prefixed name thatlibmaxminddb-1.5.0-vs16-*.zipactually ships. Either way it produces the sameassets, so the count does not move.
dev-bin/release.shthere must also stop building the tarball and creating therelease itself and just push the tag, or the two will collide. That is a
follow-up PR in the other repo, not this one.
This workflow is now exercised by its own pull request, so the run on this
branch is a dry run of everything but the release. Publishing itself still first
happens for real on a tag; a
workflow_dispatchagainst an existing tag remainsthe cheapest way to smoke that part. What was verified statically:
actionlint,zizmor(auditor persona),shellcheckoverdev-bin/,php -l, andbash -non everyrun:block extracted with a YAML parser are all clean; every jobreachable on a pull request was confirmed to hold no
writepermission and noreachable
gh releasecommand or attestation; every pinned action SHA waschecked against
the tag named in its comment;
composer.jsonis valid JSON and thephp-extblock matches PIE's documented schema; all eight pinned image digests were
confirmed to exist as multi-arch indexes with amd64 and arm64; the tarball was
reproduced in both submodule states, before and after the bundled-sources PR,
with
LICENSEasserted at the archive root in each, and thesource-buildjobwas run against both — building and querying in the post-PR state, skipping
with a warning in today's, and failing as intended when a header is withheld
from the tarball; the glibc claim was measured rather than assumed, compiling
libmaxminddb with and without
-D_GNU_SOURCE(strtol@GLIBC_2.2.5versus__isoc23_strtol@GLIBC_2.38, the only symbol in the built object above 2.36)and against a bookworm glibc 2.36 sysroot, where the floor drops to 2.33; and
the asset-count assertion was simulated for both the full and the reduced matrix
over a realistically shaped
dist/tree, with negative cases for eachplatform's assets missing, a mismatched tarball name and two artifacts sharing a
filename, and the pull-request shape in which the binary lanes are skipped; the
version resolution and the bundled-sources probe were run through every event
shape -- tag push, dispatch with and without a tag, pull request -- including
two tags the sanitisation must reject; and the staging cross-checks and the
lookup assertion were each exercised locally, negative cases included.
The bundled lanes were exercised for real before this merges. They gate on
setup'sbinariesoutput, which probes the submodule forext/libmaxminddb,and the submodule points at v1.13.1, which has neither those sources nor the
bundled mode in
config.m4/config.w32. So on this branch as it stands Linuxand macOS skip and Windows takes the fetched-library fallback — which meant the
bundled path's first real execution would have been a live release, since
dev-bin/release.shmoves that pointer to the new tag as part of cutting one.So the pointer was temporarily moved to the
--with-maxminddb-bundledbranch ofmaxmind/MaxMind-DB-Reader-php(#265), the full set of lanes was run, and thepointer was reverted. Those two commits are deliberately not part of this branch
— its tree is byte-identical to before they were made — so the evidence is here
instead.
The first attempt found a real defect, which is the entire reason for doing this
early: all four Windows legs, x64 and x86, nts and ts, built and linked
successfully and then produced a DLL PHP refused to load —
which is what PHP says when
get_moduleis not exported.ext/maxminddb.candext/libmaxminddb/src/maxminddb.cshare a basename, and whileADD_SOURCESnormally derives each object's directory from its first argument — how php-src's
ext/gdbuildslibgd/gd.cbesidegd.c— under phpizeconfutils.jstakes abranch that ignores it and uses only the directory part of each
file_listentry. Ours are bare filenames, so both resolved to
$(BUILD_DIR)\maxminddb.obj,the makefile got two rules for one target, and the extension's own object lost.
Fixed in #265 by passing the object directory explicitly; it needs no change
here.
With that fix, run
30840832024
was green across all fourteen build and verification jobs, the three release-only
jobs skipping as designed:
options: --with-maxminddb --with-maxminddb-bundledwithphp_libraries:andextension_libraries:both empty, so nothing was fetched and the link line carries no libmaxminddb
import library — the vendored 1.13.3 sources really are compiled in, where the
fallback would have given Windows the 1.5.0 PHP publishes. All three objects
now compile to distinct paths, and the extension's own test suite goes from
2 skipped plus 1 failed to
Tests passed: 3 (100.0%).NEEDED: libc.so.6alone, noRUNPATH, no undefined
MMDB_, andMeasured glibc floor: 2.33 (documented maximum 2.36)— the number this description predicts for a bookworm build,measured rather than assumed. The ceiling did not fire and
MAX_GLIBCwas nottouched.
otool -Llists only/usr/lib/libSystem.B.dylib, noLC_RPATH, no undefined_MMDB_, andMeasured minimum macOS: 11.0 (documented maximum 11.0).in a clean container under
php -n:ok maxminddb 1.13.1 on PHP 8.4.24 (nodeCount=1453).source-build: the tarball carried libmaxminddb this time, so the jobstopped skipping and built from the tarball alone, in a container asserted to
have no system libmaxminddb.
verify-assets:Expecting 11 assets, found 11— 1 tarball, 4 Linux, 2macOS, 4 Windows, exactly the reduced pull-request matrix.
The run after the revert skips the binary lanes again, as before.
Nothing in the Windows lane asserted that failure directly.
001-load.phptand002-final.phptboth--SKIPIF--on!extension_loaded('maxminddb'), so theyskipped rather than failed; only
003, which has no--SKIPIF--, failed. A buildwhose release asset PHP could not load reported 2 skipped, 1 failed, which is far
too quiet for something a release depends on.
003's failure does reach thestep's exit code, though, which turns out to matter for what this gate can be
shown to do -- see the last two paragraphs of this section.
The lane now gates the DLL it built.
dumpbin /exportsmust showget_module, anddev-bin/verify-extension.phpmust load that DLL underphp -nand complete a real lookup with it. Both are fatal, which is the point: PHPonly warns about a library it could not use and leaves the exit code at 0.
dumpbinships with MSVC but is not onPATHoutside a developer prompt, so thegate asks
vswherewhere it went. There is deliberately no Windows analogue ofthe
lddcheck — the libmaxminddb PHP publishes for Windows is a static.libwith no DLL beside it, so the extension imports no libmaxminddb whichever way it
was built, and an assertion that holds in both modes would say nothing about
which one ran. Dropping those two
--SKIPIF--guards is the general form of thefix and belongs in
maxmind/MaxMind-DB-Reader-php, so it is a follow-up there.The gate was exercised in both Windows build modes, since a pull request only
ever reaches one of them.
Run
30842452545
is this branch as it stands: the pointer at v1.13.1, so
binaries=falseandWindows takes the fetched-library fallback. All four legs green, and the gate
ran on each rather than skipping:
Run
30842932447
moved the pointer to 1643998 again so that
binaries=trueand Windows took thebundled build — the mode a release will use. Green across all fourteen build
and verification jobs,
Expecting 11 assets, found 11, and the gate on all fourlegs there too:
x86 reports the same through its decorated name,
get_module = _get_module, andtsthroughRelease_TS\php_maxminddb.dll.vswherelocateddumpbinon alleight legs; nothing degraded to a warning. Nothing in the gate is conditioned on
the build mode, and there is nothing in it that could only hold in one. The
pointer was reverted straight afterwards, so the tree is once again identical to
what it was before those two commits and
git ls-tree HEAD MaxMind-DB-Reader-phpis back at 2194f58.
Its rejection path, though, is still unobserved. Run
30844570591
moved the pointer to
b969d83-- 1643998 with that fourthADD_SOURCESargumenttaken back off -- to watch the gate reject a DLL it ought to reject. The defect
reproduced exactly, and the makefile said so out loud:
Both translation units claimed one object, libmaxminddb's compiled second and
won, and the link then succeeded anyway with only an
LNK4042. So the buildproper did succeed, exactly as the original report describes. What follows is
still inside the same Build the extension step, because the action runs the
phpt suite there too:
So the gate never ran.
Invoke-Tests.ps1throws, the step goes red on all fourlegs, its own Upload the build artifact is skipped, and Gate the built
DLL -- which carries no
if:, so it defaults tosuccess()-- is skipped too.The distinction that matters: the build succeeded and the step still failed, so
the gate was never reached.
So the defect is caught today, but by the action's own test run rather than by
this gate. Run
30840224616,
the same defect before the gate existed, failed the same step on the same four
legs, and neither run uploaded a Windows artifact -- 7 apiece, none of them a
DLL, against 11 on the green bundled run above. The gate is still worth keeping:
it asserts under
php -nwith nophp.inito help, and it does not depend on theaction continuing to run tests for us. But it has not been shown to stop
anything, and the honest summary is that it is unproven rather than proven.
Giving the step
if: ${{ !cancelled() }}, so it also runs when the build stepfails, is what would both put it in front of this defect and make its rejection
path observable; that is a change to the gate's wiring and belongs in its own
commit, so it is a follow-up.
Generated by Claude Code