Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e0f466a
docs: design PR-gated v2 drop-in compat testing
phramz Apr 24, 2026
c51ccd8
feat(testsuite): add Fixture.Compat flag and mark 8 axis fixtures
phramz Apr 24, 2026
7a3ac6d
feat(compatdiff): expose DiffFiles + Deviation for in-process use
phramz Apr 24, 2026
14d5a11
feat(testsuite): mount compat-matrix.md + goldens dir into test-cell
phramz Apr 24, 2026
053a610
feat(testsuite): per-cell deviation artifact writer
phramz Apr 24, 2026
65d27ce
feat(testsuite): run compat-diff per fixture in canonical cell
phramz Apr 24, 2026
d6aec32
feat(testsuite): add phpup internal golden-capture subcommand
phramz Apr 24, 2026
36ee94c
build(make): add compat-refresh-goldens target
phramz Apr 24, 2026
01f95c1
feat(ci): weekly + on-demand v2 compat golden refresh workflow
phramz Apr 24, 2026
66fbef1
feat(ci): compat-report job posts sticky PR comment on compat deviations
phramz Apr 24, 2026
b22d47d
fix(ci): add pull_request trigger to compat-golden-refresh for bootstrap
phramz Apr 24, 2026
18dafcd
fix(ci): detect untracked goldens in drift detection
phramz Apr 24, 2026
1ba5e79
fix(ci): use head_ref as PR base on pull_request events
phramz Apr 24, 2026
935e739
chore: refresh v2 compat goldens
phramz Apr 24, 2026
d5d903d
chore: exclude v2 goldens from prettier
phramz Apr 24, 2026
f00a065
fix(testsuite): mount golden/v2 subdir directly as /golden
phramz Apr 24, 2026
98e152f
fix(ci): replace jq backtick escapes with string concatenation
phramz Apr 24, 2026
af7ecc5
fix(ci,docs): serialize refresh workflow + align spec with cleared ma…
phramz Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ jobs:
# "do the normal cache-probe".
FORCE: ${{ inputs.force && '1' || '' }}
run: make ci-cell OS=${{ matrix.os }} ARCH=${{ matrix.arch }} PHP=${{ matrix.php }} FORCE="$FORCE"
- name: Upload deviation artifacts (compat fail only)
if: failure() && matrix.os == 'noble' && matrix.arch == 'x86_64' && matrix.php == '8.4'
uses: actions/upload-artifact@v7
with:
name: compat-deviations-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.php }}
path: .cache/phpup-test/deviations/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.php }}.json
if-no-files-found: ignore
retention-days: 7
- name: Upload oci-layout artifact (main only)
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -188,6 +196,104 @@ jobs:
|| { echo "::error::memory_limit=$memlim, want 256M"; exit 1; }
echo "smoke-action: action surface OK"

compat-report:
name: Compat report (PR comment)
needs: pipeline
if: always() && github.event_name == 'pull_request' && needs.pipeline.result != 'skipped'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
pattern: compat-deviations-*
path: /tmp/compat-deviations
merge-multiple: true
- name: Render comment body
id: render
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
mkdir -p /tmp/compat-deviations
body_file=/tmp/compat-report-body.md
found=0
for f in /tmp/compat-deviations/*.json; do
[ -f "$f" ] || continue
found=1
break
done
if [ $found -eq 1 ]; then
{
echo "## ⚠️ Compat-diff detected deviations from shivammathur/setup-php@v2"
echo ""
echo "<!-- compat-report-comment-v1 -->"
echo ""
echo "This PR's \`phpup install\` behavior diverges from the pinned v2 baseline in ways the compat-matrix allowlist doesn't cover."
echo ""
echo "### Deviations"
echo ""
for f in /tmp/compat-deviations/*.json; do
cell=$(jq -r '.cell' "$f")
# jq's JSON-string escape rules reject `\``; build the
# markdown with string concatenation and literal
# backticks instead.
jq -r --arg cell "$cell" '.fixtures[] | "**Fixture `" + .name + "` (" + $cell + ")**\n" + (.deviations[:20] | map("- `" + .Path + "`: ours=`" + .Ours + "`, theirs=`" + .Theirs + "`") | join("\n")) + "\n"' "$f"
done
echo ""
echo "### What to do"
echo ""
echo "1. **If this is a regression in your change** — fix it, or add an entry to the deviations allowlist in [\`docs/compat-matrix.md\`](../blob/main/docs/compat-matrix.md) with a \`reason:\` explaining why it's intentional. The YAML block is delimited by \`<!-- compat-harness:deviations:start -->\` / \`<!-- compat-harness:deviations:end -->\`."
echo ""
echo "2. **If this looks like upstream v2 drift** (e.g., the PPA shipped a new package between golden refreshes) — ask a maintainer to force-refresh the goldens:"
echo ""
echo " \`\`\`"
echo " gh workflow run compat-golden-refresh.yml"
echo " \`\`\`"
echo ""
echo " Merge the resulting drift PR (\`chore: refresh v2 compat goldens\`), then rebase this PR onto \`main\`."
} > "$body_file"
echo "status=deviations" >> "$GITHUB_OUTPUT"
else
# No deviations this run.
{
echo "✅ Compat-diff cleared — this PR's \`phpup install\` now matches the pinned v2 baseline on all 8 axis fixtures."
echo ""
echo "<!-- compat-report-comment-v1 -->"
} > "$body_file"
echo "status=cleared" >> "$GITHUB_OUTPUT"
fi
{
echo "body_path=$body_file"
} >> "$GITHUB_OUTPUT"

- name: Find existing sticky comment
id: find
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: <!-- compat-report-comment-v1 -->

- name: Post/update deviations comment
if: steps.render.outputs.status == 'deviations'
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}
body-path: ${{ steps.render.outputs.body_path }}
edit-mode: replace

- name: Update cleared comment (only if sticky exists)
if: steps.render.outputs.status == 'cleared' && steps.find.outputs.comment-id != ''
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}
body-path: ${{ steps.render.outputs.body_path }}
edit-mode: replace

publish:
name: Publish to GHCR
needs: [pipeline, smoke-action]
Expand Down
201 changes: 201 additions & 0 deletions .github/workflows/compat-golden-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
name: compat-golden-refresh

# Re-captures v2 compat probes weekly and opens a drift PR if the bytes
# changed. Also exposed via workflow_dispatch so maintainers can
# force-refresh on demand (e.g. to unblock a feature PR that went red
# on compat because v2 shipped something new between scheduled runs).
#
# The `pull_request` trigger (scoped to this file's own path) serves two
# purposes: (1) it bootstraps the initial golden capture during the
# feature PR that first introduces this workflow — workflow_dispatch
# requires the workflow file to already live on the default branch, and
# the pull_request trigger sidesteps that chicken-and-egg; (2) any
# subsequent edit to this workflow re-runs it, so we never merge a
# broken capture pipeline. Scoped to just this file so unrelated PRs
# don't redundantly re-capture.

on:
schedule:
- cron: "0 12 * * MON"
workflow_dispatch:
pull_request:
paths:
- .github/workflows/compat-golden-refresh.yml

permissions:
contents: write
pull-requests: write

# Serialize refresh runs on the shared `chore/compat-golden-refresh`
# branch. Without this, a Monday cron firing while a PR-trigger run
# from a workflow-file edit is still in-flight would race on
# peter-evans/create-pull-request — both fighting over the same
# branch would either force-push each other's commits or open
# duplicate PRs. cancel-in-progress=false lets the earlier run
# finish cleanly before the newer one starts, which is the correct
# posture for a side-effecting (PR-opening) workflow.
concurrency:
group: compat-golden-refresh
cancel-in-progress: false

env:
# Pinned v2 SHA. MUST match `## Pinning` in docs/compat-matrix.md.
# The sanity-check job reads the markdown and refuses to run if the
# two values disagree.
PINNED_V2_SHA: accd6127cb78bee3e8082180cb391013d204ef9f

jobs:
sanity:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
last_refreshed: ${{ steps.stamp.outputs.date }}
steps:
- uses: actions/checkout@v6
- name: Verify PINNED_V2_SHA matches docs/compat-matrix.md
run: |
# Extract the SHA from the "Commit SHA (dereferenced)" row of the
# compat-matrix.md pinning table. Using awk with backtick as the
# field separator keeps this self-consistent even when the env's
# PINNED_V2_SHA changes — we read what's in the doc, then
# compare. Any drift between doc and env fails the workflow
# fast with an informative message.
doc_sha="$(awk -F'`' '/Commit SHA \(dereferenced\)/ {print $2; exit}' docs/compat-matrix.md)"
if [ "$doc_sha" != "$PINNED_V2_SHA" ]; then
echo "::error::docs/compat-matrix.md pins '$doc_sha' but workflow env is '$PINNED_V2_SHA' — update one to match the other"
exit 1
fi
- id: stamp
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"

capture:
needs: sanity
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
fixture:
- { name: bare, php: "8.4", extensions: "", ini_values: "", coverage: "none", ini_file: "" }
- { name: exclusion, php: "8.4", extensions: ":opcache", ini_values: "", coverage: "none", ini_file: "" }
- { name: single-ext, php: "8.4", extensions: "redis", ini_values: "", coverage: "none", ini_file: "" }
- { name: multi-ext, php: "8.4", extensions: "redis, xdebug", ini_values: "", coverage: "none", ini_file: "" }
- { name: none-reset, php: "8.4", extensions: "none, redis", ini_values: "", coverage: "none", ini_file: "" }
- { name: coverage-pcov, php: "8.4", extensions: "", ini_values: "", coverage: "pcov", ini_file: "" }
- { name: ini-and-coverage, php: "8.4", extensions: "", ini_values: "memory_limit=256M,date.timezone=UTC", coverage: "xdebug", ini_file: "" }
- { name: ini-file-development, php: "8.4", extensions: "", ini_values: "", coverage: "none", ini_file: "development" }
steps:
- uses: actions/checkout@v6
- name: Snapshot env + PATH before setup-php
run: |
env > /tmp/env-before
printf "%s" "$PATH" > /tmp/path-before
- name: Setup PHP (pinned v2)
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f
with:
php-version: ${{ matrix.fixture.php }}
extensions: ${{ matrix.fixture.extensions }}
ini-values: ${{ matrix.fixture.ini_values }}
coverage: ${{ matrix.fixture.coverage }}
ini-file: ${{ matrix.fixture.ini_file }}
- name: Probe
run: |
chmod +x test/compat/probe.sh
bash test/compat/probe.sh \
/tmp/golden-${{ matrix.fixture.name }}.json \
/tmp/env-before \
/tmp/path-before \
test/compat/ini-keys.txt
- uses: actions/upload-artifact@v7
with:
name: golden-${{ matrix.fixture.name }}
path: /tmp/golden-${{ matrix.fixture.name }}.json
retention-days: 3

aggregate:
needs: [sanity, capture]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
pattern: golden-*
path: /tmp/golden-raw
merge-multiple: true
- name: Stage captured goldens into the repo tree
run: |
mkdir -p test/compat/testdata/golden/v2
# Each artifact name is golden-<fixture>; the file inside is
# named golden-<fixture>.json. Normalize to <fixture>.json.
for f in /tmp/golden-raw/golden-*.json; do
name="$(basename "$f" .json)"
dest_name="${name#golden-}"
cp "$f" "test/compat/testdata/golden/v2/${dest_name}.json"
done
ls -la test/compat/testdata/golden/v2/
- name: Detect drift
id: drift
run: |
# Use `git status --porcelain` rather than `git diff --quiet`
# because the latter ignores untracked files — on the initial
# capture (goldens dir previously empty) every JSON is a new
# file, not a modification to a tracked one. The porcelain
# output is non-empty iff anything under the path differs
# from HEAD, covering adds, modifications, and deletions.
if [ -z "$(git status --porcelain test/compat/testdata/golden/v2/)" ]; then
echo "drift=false" >> "$GITHUB_OUTPUT"
echo "No v2 drift detected." >> "$GITHUB_STEP_SUMMARY"
else
echo "drift=true" >> "$GITHUB_OUTPUT"
{
echo "### Per-fixture golden changes"
git status --porcelain test/compat/testdata/golden/v2/
echo
echo "### Per-fixture diff stats (modifications only)"
git diff --stat test/compat/testdata/golden/v2/ || true
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Open drift PR
if: steps.drift.outputs.drift == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: refresh v2 compat goldens"
branch: chore/compat-golden-refresh
delete-branch: true
# Base branch picker by event type:
# - schedule / workflow_dispatch: github.ref_name is the
# branch the workflow is running against (`main` for
# scheduled runs; maintainer-chosen ref for dispatch).
# - pull_request: github.ref_name is the synthetic
# `<pr-number>/merge` ref (not a real branch) — we need
# github.head_ref, which is the PR's source branch.
# This ternary keeps a single rolling drift PR targeting
# whatever branch the workflow fired from.
base: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
title: "chore: refresh v2 compat goldens"
body: |
Weekly (or on-demand) refresh of the pinned v2 compat probes.

**Pinned v2 SHA:** `${{ env.PINNED_V2_SHA }}`
**Captured at:** `${{ needs.sanity.outputs.last_refreshed }}` UTC

### Drift summary

Review `git diff` on `test/compat/testdata/golden/v2/` to see
which fixture(s) shifted and which JSON paths moved.

### How to decide

- **Allowlist already tolerates the drift** — safe to merge;
PR-time compat-diff would not have flagged it. Merging just
keeps goldens close to live v2.
- **Allowlist does NOT tolerate the drift** — merging this PR
makes the new v2 behavior the PR-time baseline. Open a
separate PR to either (a) match the new v2 behavior in our
action or (b) extend the allowlist in `docs/compat-matrix.md`
with a `reason:`.

Opened by `.github/workflows/compat-golden-refresh.yml`.
labels: compat-refresh, chore
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Captured v2 compat probes — generated by
# .github/workflows/compat-golden-refresh.yml via test/compat/probe.sh.
# Prettier's JSON style (one array element per line) would conflict
# with probe.sh's compact output, forcing every refresh PR to also
# reformat. These are frozen data, not source.
test/compat/testdata/golden/v2/
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: check fmt fmt-check vet lint tidy tidy-check test test-node build clean \
build-linux-amd64 build-linux-arm64 bundle-php bundle-ext gc-bundles-dry-run \
ci-cell ci
ci-cell ci compat-refresh-goldens

# Path to the native phpup binary used by bundle-php / bundle-ext. Overridable
# so CI / power users can point at a pre-built binary.
Expand Down Expand Up @@ -179,6 +179,18 @@ ci: $(PHPUP_BIN)
done; \
done

# compat-refresh-goldens: run `phpup internal golden-capture` against the
# current PHP environment. Intended for use INSIDE the
# compat-golden-refresh.yml workflow after `shivammathur/setup-php@<sha>`
# has populated the runner. Running it on a developer laptop without
# having first installed v2 will produce a golden that reflects the
# laptop's PHP, not v2's — do not commit that output.
compat-refresh-goldens: $(PHPUP_BIN)
$(PHPUP_BIN) internal golden-capture \
--fixtures test/compat/fixtures.yaml \
--probe test/compat/probe.sh \
--out-dir test/compat/testdata/golden/v2

# Clean build artifacts
clean:
rm -rf bin/ dist/
Expand Down
Loading
Loading