From 26daf4e342b876e1d0ce4e4a424d28f55fb99fca Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 25 Jul 2026 12:25:06 -0600 Subject: [PATCH] fix(drupal-contrib): install Drush via Guzzle alias on Drupal 12 HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drupal core `main` moved to `guzzlehttp/guzzle: "^8.0"` on 2026-07-24 (drupal.org #3612544) while every Drush version still requires `^7.0`. The `skipto` D12 cell has failed since, with all three `ddev poser` attempts dying at dependency resolution: - drush/drush 14.x-dev requires guzzlehttp/guzzle ^7.0 -> satisfiable by guzzlehttp/guzzle[7.5.x-dev, ..., 7.15.x-dev] - You can only install one version of a package ...: guzzlehttp/guzzle[ ..., 8.0.0, 8.0.x-dev] No `web/index.php` gets created, which is the assertion that fails. The break is module-agnostic — any module on D12 hits it, and the D11 cells pass. Contrib was green through the 07-24 nightly and red from 07-25. #178 fixed the same conflict in drupal-core and deliberately scoped this template out, because its technique does not transfer: there the exact Guzzle version is read from `composer.lock` after `composer install` has already succeeded, but `ddev poser` resolves core and Drush in a single solve, so the solve fails before any lock exists (and poser deletes `composer.contrib.lock` afterwards). #178's Notes and #179's Related section both call for a separately tested approach here. This is it. - When a poser attempt fails *and* its output shows the Drush/Guzzle conflict, add `"guzzlehttp/guzzle": "<8.x> as 7.99.0"` to the module's `require-dev` and retry immediately. `expand_composer_json.php` merges root `require-dev` into `composer.contrib.json`, so the alias reaches the install. The exact version comes from packagist, since Composer rejects a range as an alias source and there is no lock to read. - Gated on that specific failure, so D11 (core requires Guzzle `^7`) keeps resolving untouched, and the workaround self-clears the day Drush is fixed. Removal is tracked in #179. - Capture each attempt's output to its own file. `$SETUP_LOG` is append-only across workspace restarts, so grepping it could match a stale conflict from an earlier start and wrongly pin Guzzle 8 on a D11 build. - Mirror the fallback in `drupal-contrib/scripts/test-issue-branches.sh`, and capture poser's exit code there — the previous `cmd | tail -10` form tested `tail`'s status, so a poser failure went undetected. - Make the PR smoke cell `skipto` D12 instead of `token` D11. The PR matrix ran only D11, which is why this reached main green. Same one-workspace cost; D12 tracks core HEAD, so it breaks first, and the D11 cells still run on push-to-main and nightly. Unlike drupal-core, where the alias lands in gitignored `composer.local.json`, here it lands in the module's tracked `composer.json`. The existing `git update-index --skip-worktree composer.json` call covers it, same as the drush `require-dev` line. Verified against real DDEV on `skipto` with `DRUPAL_CORE=^12`: plain poser exits 1 with the CI conflict; the fallback installs `drupal/core dev-main` + `drush/drush 14.x-dev` + `guzzlehttp/guzzle 8.0.0` together; `web/index.php` present; `drush si minimal` reports `[OK] Installation complete`; `Database: Connected`; `drush en skipto` installs and `pm:list --status=enabled` lists it; `drush status` reports Drupal 12.0-dev. Also `terraform fmt -recursive` clean, `terraform validate` and `terraform test` pass for drupal-contrib (6 passed), `bash -n` clean on the script, workflow YAML parses with no new actionlint findings. Co-Authored-By: Claude Opus 5 (1M context) --- .../drupal-contrib-integration-test.yml | 5 ++- drupal-contrib/scripts/test-issue-branches.sh | 20 +++++++++- drupal-contrib/template.tf | 40 ++++++++++++++++++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/.github/workflows/drupal-contrib-integration-test.yml b/.github/workflows/drupal-contrib-integration-test.yml index 97769f9..af378e7 100644 --- a/.github/workflows/drupal-contrib-integration-test.yml +++ b/.github/workflows/drupal-contrib-integration-test.yml @@ -393,8 +393,11 @@ jobs: strategy: # PRs run a single module (smoke); push-to-main and nightly run the full # set. Cuts per-PR workspace load on staging. See ci-reap.yml. + # The smoke cell is the D12 one on purpose: it tracks Drupal core HEAD, so + # it is where dependency churn (e.g. core's Guzzle 8 bump) breaks first. + # The D11 cells are stable and covered on push-to-main and nightly. matrix: - include: ${{ github.event_name == 'pull_request' && fromJSON('[{"project":"token","drupal_version":"11"}]') || fromJSON('[{"project":"token","drupal_version":"11"},{"project":"pathauto","drupal_version":"11"},{"project":"skipto","drupal_version":"12"}]') }} + include: ${{ github.event_name == 'pull_request' && fromJSON('[{"project":"skipto","drupal_version":"12"}]') || fromJSON('[{"project":"token","drupal_version":"11"},{"project":"pathauto","drupal_version":"11"},{"project":"skipto","drupal_version":"12"}]') }} fail-fast: false defaults: run: diff --git a/drupal-contrib/scripts/test-issue-branches.sh b/drupal-contrib/scripts/test-issue-branches.sh index 56c56c1..8db504d 100755 --- a/drupal-contrib/scripts/test-issue-branches.sh +++ b/drupal-contrib/scripts/test-issue-branches.sh @@ -135,7 +135,25 @@ for SPEC in "${TESTS[@]}"; do ddev exec composer require --dev drush/drush --no-update --no-interaction 2>&1 | tail -5 log "Running ddev poser..." - if ! ddev poser 2>&1 | tail -10; then + POSER_RC=0 + ddev poser > /tmp/poser-attempt.log 2>&1 || POSER_RC=$? + tail -10 /tmp/poser-attempt.log + # Mirrors the Guzzle alias fallback in template.tf: Drush still requires + # guzzlehttp/guzzle ^7.0 while core main requires ^8.0, which makes a plain + # poser run unsolvable on Drupal 12 HEAD. Removal tracked in #179 + # (upstream: drush-ops/drush#6602). + if [ "$POSER_RC" != "0" ] && grep -q "drush/drush.*requires guzzlehttp/guzzle" /tmp/poser-attempt.log; then + GUZZLE_VER=$(curl -fsSL --max-time 30 https://repo.packagist.org/p2/guzzlehttp/guzzle.json 2>/dev/null | + jq -r '[.packages["guzzlehttp/guzzle"][].version | select(test("^8[.][0-9]+[.][0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty' 2>/dev/null || true) + if [ -n "$GUZZLE_VER" ]; then + log "Drush/Guzzle conflict — retrying poser with guzzle $GUZZLE_VER aliased as 7.99.0..." + jq --arg v "$GUZZLE_VER as 7.99.0" '.["require-dev"]["guzzlehttp/guzzle"] = $v' composer.json > composer.json.tmp && mv composer.json.tmp composer.json + POSER_RC=0 + ddev poser > /tmp/poser-attempt.log 2>&1 || POSER_RC=$? + tail -10 /tmp/poser-attempt.log + fi + fi + if [ "$POSER_RC" != "0" ]; then log "ERROR: ddev poser failed" RESULTS["$DIR_KEY"]="FAIL (ddev poser)" DURATIONS["$DIR_KEY"]=$((SECONDS - START)) diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 847dec5..d099389 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -750,11 +750,18 @@ COMPOSE_EOF # then runs composer install (installs Drupal + drush together), then removes composer.contrib.json # Retry up to 3 times to handle transient codeload.github.com 400s. _poser_ok=false + _guzzle_aliased=false for _attempt in 1 2 3; do log_setup "Running ddev poser (attempt $_attempt/3)..." update_status "⏳ ddev poser: Attempt $_attempt/3..." _t=$SECONDS - if ddev poser >> "$SETUP_LOG" 2>&1; then + # Capture this attempt separately so the conflict check below cannot + # match output from an earlier attempt or an earlier workspace start + # ($SETUP_LOG is append-only across restarts). + _poser_rc=0 + ddev poser > /tmp/poser-attempt.log 2>&1 || _poser_rc=$? + cat /tmp/poser-attempt.log >> "$SETUP_LOG" + if [ "$_poser_rc" = "0" ]; then log_setup "✓ ddev poser complete ($((SECONDS - _t))s)" update_status "✓ ddev poser: Success" # Hide the drush require-dev line from git status without touching the @@ -765,6 +772,37 @@ COMPOSE_EOF break fi log_setup "✗ ddev poser attempt $_attempt failed ($((SECONDS - _t))s)" + + # Drush's Guzzle constraint lags core's: core main moved to + # guzzlehttp/guzzle ^8.0 on 2026-07-24 (drupal.org #3612544) while + # every Drush version still requires ^7.0, so Composer cannot solve + # drush/drush against Drupal 12 HEAD. Guzzle 8 works with Drush at + # runtime, so retry with an inline alias presenting a Guzzle 8 release + # as a 7.x one — the same workaround #178 added to drupal-core, which + # deliberately left this template alone because the version cannot be + # read from a lock file here: ddev poser resolves core and Drush in one + # solve and deletes composer.contrib.lock afterwards. Composer only + # accepts an exact version as an alias source, so ask packagist for the + # newest stable 8.x instead. Applied only on this specific failure, so + # Drupal 11 builds (core requires Guzzle ^7) keep resolving normally, + # and it self-clears once Drush is fixed. Unlike drupal-core, where the + # alias lands in gitignored composer.local.json, here it lands in the + # module's tracked composer.json — the skip-worktree call above keeps + # it out of git status, same as the drush require-dev line. Removal is + # tracked in #179 (upstream: drush-ops/drush#6602). + if [ "$_guzzle_aliased" = "false" ] && \ + grep -q "drush/drush.*requires guzzlehttp/guzzle" /tmp/poser-attempt.log; then + _guzzle_ver=$(curl -fsSL --max-time 30 https://repo.packagist.org/p2/guzzlehttp/guzzle.json 2>/dev/null | + jq -r '[.packages["guzzlehttp/guzzle"][].version | select(test("^8[.][0-9]+[.][0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty' 2>/dev/null || true) + if [ -n "$_guzzle_ver" ]; then + log_setup "Drush/Guzzle constraint conflict — retrying with guzzle $_guzzle_ver aliased as 7.99.0..." + jq --arg v "$_guzzle_ver as 7.99.0" '.["require-dev"]["guzzlehttp/guzzle"] = $v' composer.json > composer.json.tmp && mv composer.json.tmp composer.json + _guzzle_aliased=true + continue + fi + log_setup "⚠ Could not determine a stable Guzzle 8 version from packagist" + fi + if [ "$_attempt" -lt 3 ]; then log_setup "Retrying in 15s..." sleep 15