composer drupal-patch-check

A composer plugin created to make it easy for humans to upgrade their patched Drupal websites.

If you ever wanted to know if you still needed that patch after an update, or if your custom patch would still apply to the new version of the module, or if your 15 levels patch tower still applies after the update, this is the tool for you. Install the composer plugin and get started using the remote API.

$ composer require --dev tresbientech/drupal-patch-check

 If you want the short version, feel free to read the package readme

What is it?

The composer plugin will help with patches on public drupal.org modules. It doesn't check or even see the private or custom modules used on your website. This works by calling api.tresbien.tech/v1/composer/scan with a filtered version of your composer.json, composer.lock files as well as the contents of all the associated patches, only for public drupal.org modules. The endpoint is fully controlled by myself, a Drupal core committer, and the data processed will be used to inform how we can prioritize issues for example. There is an option to obfuscate patch paths to avoid disclosing ticket numbers and such. 

The API in turns respond with a rich set of data we're going to explore on a real life composer.json I had to clean up earlier this year by hand.

Starting the patch clean-up

The initial composer.json was broken, a composer install that would not install, and when it finally did patches would barely apply. First step in that situation once the composer install actually finishes:

$ composer drupal-patch-check

// or the cuter alternative
$ composer drupatch:check

With that I know what will need work and what can be quickly sorted out. Let's review the output:

Summary

Drupal Patch Check: 51 patches against the releases this site installs

simplesamlphp/simplesamlphp   1 patch skipped (not a drupal.org project)
acquia/cohesion 8.2.1   6 patches skipped (not a drupal.org project)

Tells you how many patches total you have in the composer.json, and all the modules and patches that will not be reviewed because they're not against public Drupal modules. To be clear this data is from your local composer plugin, the API never see the list of private modules. The names of custom modules are never sent away. If you want to review what is sent out before using this tool, you can run composer drupatch:check --dry-run, it will output the exact JSON sent to the API. 

Patch data

There are various situation your patch can be in. Starting with the easy ones. 

Everything is fine, or fine enough:

drupal/entity_reference_revisions 1.12.0   1 applies
  #1 · applies   Draft translations should be based on the lat…  moderation-translation-err-rc1-…
     
drupal/permissions_by_term 3.1.40   1 applies
  #1 · applies   Extend permissions by edit and create permiss…  access_for_view_nodes.patch
                 context drifted, needed: git apply -p1 -C1 --ignore-whitespace --recount

First patch applies no problems, next patch targets an older version of the module and a few lines of context changed, not a problem, this will install without warning.

Looks bad, great news:

drupal/geoip dev-3.x   1 conflicts
  #1 ! conflicts Automated Drupal 10 compatibility fixes         gepop.3.0.x-update-to-d10.patch
                 geoip.info.yml:1: patch failed
                 tests/src/Kernel/GeoLocatorPluginTest.php:31: already in the release, not needed
                 tests/src/Kernel/RequirementsTest.php:19: already in the release, not needed
                 tests/src/Kernel/RequirementsTest.php:31: already in the release, not needed
                 tests/src/Unit/CdnGeoLocatorTest.php:2: patch failed
                 tests/src/Unit/CdnGeoLocatorTest.php:17: patch failed
                 tests/src/Unit/GeoLocationTest.php:2: patch failed
                 run composer drupatch:reroll to see if the release has the rest

Look at that last line, the whole reason I started working on this tool. If parts of the patch are in the release, there is a good chance the patch is not needed anymore. We'll get confirmation on the next command. Spoiler alert, it's not needed anymore.

The patch tower™:

drupal/core 11.2.5   4 conflicts, 11 applies
  #1 ! conflicts Fix Drupal Views filters                        boolean-no-value-2862828.patch
                 core/modules/views/src/Plugin/views/filter/BooleanOperator.php:2: patch failed
                 core/modules/views/src/Plugin/views/filter/BooleanOperator.php:108: patch failed
                 core/modules/views/src/Plugin/views/filter/BooleanOperator.php:149: patch failed
  #2 · applies   Error after saving node drupal 8.7 issue        ignore_width_height_on_untransl…
                 judged after #1 applied in part
(…)
  #7 · applies   Argument 1 passed                               3243387-argument-1-passed-8.pat…
                 judged with only the part of #1 that applied
  #8 ! conflicts TIC-316: Patched parent links (https://www.dr…  3110371-56.patch
                 core/modules/menu_link_content/src/Form/MenuLinkContentForm.php:92: patch failed
                 judged after #1 applied in part
(…)
 #15 · applies   MenuTreeStorage shouldn't invalidate cache ta…  10023.diff
                 judged after #1 applied in part

The classic 15 patch Drupal core install where one patch introduces a change, and a second or third patch modifies the contents of the previous patch. Usually it's a custom change applied on top of a public patch from drupal.org and it's pretty common. The very first patch fails to apply, awesome… Since this will not stop other patches from being applied on composer install, we continue with the parts that could apply properly. This is explained by the "judged after #1 applied in part" line. In the end we have 11 patches that apply properly and 4 where there are conflicts on the drupal/core package.

Footer

patches: 35 applies, 16 conflicts
composer applied these patches at install, so the files on disk show them

Next:  composer drupatch:reroll   writes the 16 re-rolls

Our 35 patches to review turned into 16 patches to review. Not bad. Now to deal with the 16 patches remaining we apparently need to be calling drupatch:reroll, we're doing that next.

Fixing problems

Let's see how much of the remaining 16 patches can we figure out automatically

$  composer drupatch:reroll

Big picture

Drupal Patch Check: 51 patches against the releases this site installs

patches: 2 now apply, 13 conflicts left, 1 to drop

That's 3 patches taken care of, 13 to have a look at. Much better than the 51 from the start.

Not needed anymore:

already in the release, drop it:
  drupal/geoip: Automated Drupal 10 compatibility fixes
    https://www.drupal.org/files/issues/2023-06-16/gepop.3.0.x-update-to-d10.patch

The whole point of this exercise. We found a patch we don't need and that can be removed. It didn't show up on the initial scan because there is some extra processing done when calling reroll that would be a bit too expensive to run on the initial scan all the time.

Rerolled automatically:

re-rolled:
 patches/core/boolean-no-value-2862828.patch  (verified against the release)
   the merge kept both additions in 2 regions, check it:
     core/modules/views/src/Plugin/views/filter/BooleanOperator.php:194
     core/modules/views/src/Plugin/views/filter/BooleanOperator.php:356
 patches/allowed_languages/access-denied-page-builder.patch  (verified against the release)

Taking a closer look at the "2 now apply" patches. Git was able to figure out the merge, excellent. Patches contents are updated, no more errors on composer install for these two.

Conflits:

re-rolled with conflicts:
 patches/acquia_dam/acquia_dam_versioning_cron_timestamp_fix.conflict.patch  (1 region to decide)
   src/Cron.php region 0
 patches/addtoany/add_SRI_to_JS_file.conflict.patch  (1 region to decide)
   addtoany.libraries.yml region 0
 patches/diff/3359192-display-current-revision-11.conflict.patch  (3 regions to decide)
   src/Form/RevisionOverviewForm.php region 0
   src/Form/RevisionOverviewForm.php region 1
   src/Form/RevisionOverviewForm.php region 2
 patches/moderated_content_bulk_publish/fix_publish_latest_revision.conflict.patch  (1 region to decide)
   src/AdminModeratedContent.php region 0
   the merge kept both additions in 1 region, check it:
     src/AdminModeratedContent.php:258

That's where we start to need to look at some code and make decisions about what to keep or remove from the patch. The tool will point to the patch, the file, and region that needs to be resolved. Once it's resolved another command will apply them to the patch we'll see that afterwards. 

Can't reroll:

Sometimes patches are not well enough structured to get them evaluated, the patches need some work to get the service to be able to do anything with them. There is some guidance about what is wrong with the patches for the service to be able to do something with them.

not re-rolled:
 it is declared as a URL, so there is no file to replace
   https://git.drupalcode.org/project/drupal/-/merge_requests/4098.diff  drupal/core: Warning: Undefined array key "#access" in language_form_alter()
   https://www.drupal.org/files/issues/2024-01-11/3110371-56.patch  drupal/core: TIC-316: Patched parent links (https://www.drupal.org/node/3250632)
 cannot re-roll: no release takes this patch, and it does not say which one it was made from; remake it with git diff or re-roll it by hand
   patches/core/fix-content-language-expansion-logic.patch  drupal/core: Fix content language expansion logic https://www.drupal.org/project/drupal/issues/3414415
   patches/acquia_dam/set-placeholder-images.patch  drupal/acquia_dam: Set placeholder image if DAM is not responding
   patches/acquia_dam/TIC-1341_Fix_SVG_dam_rendering_with_no_width_height.patch  drupal/acquia_dam: Fix SVG width height error
   patches/acquia_dam/TAC-1415___set_dam_assets_cache_to_1_month.patch  drupal/acquia_dam: Set DAM assets cache to 1 month
   patches/acquia_dam/SVG_as_Acquia_DAM__Image.patch  drupal/acquia_dam: SVG_as_Acquia_DAM__Image
   patches/simple_sitemap/TAC-1421___remove_translation_set.patch  drupal/simple_sitemap: Remove translation set
 error: repository lacks the necessary blob to perform 3-way merge.
   patches/tmgmt/tic_648_metatags_tokens_and_fix.patch  drupal/tmgmt: TIC-648: Fixed metatag tmgmt + filter tokens

Footer

Next:  composer drupatch:reroll            sends the regions you decide in the 4 conflict files
       composer drupatch:reroll --update   drops the shipped entry from composer.json and adopts the 2 patches declared as URLs

Now we're almost out of the woods. Resolve the conflicts, deal with the 9 patches the service can't help with. Eventually after running composer drupatch:reroll enough times patch status will be clean.

After some work I got to a clean composer install state. The site is still outdated, new core and contrib releases went out, what happens when we try to update the site? which patches will break? 

Updating to major version

Clean-up is done, our Drupal 10 version is clean, no patch problems. To go from there to Drupal 11 we can run the drupatch command with a target version:

$ composer drupatch:check --target=11.4.6

The header is the same pretty much, a few patches changed to make it work so the count is different, not really interesting. The upgrade data is though:

drupal/core 10.2.4 → 11.4.6   5 conflicts, 10 applies
  #1 ! conflicts Fix Drupal Views filters                        boolean-no-value-2862828.patch
                 core/modules/views/src/Plugin/views/filter/BooleanOperator.php:2: patch failed
                 core/modules/views/src/Plugin/views/filter/BooleanOperator.php:108: patch failed
                 core/modules/views/src/Plugin/views/filter/BooleanOperator.php:149: patch failed
(…)

                 
drupal/yoast_seo 2.0.0-alpha10 → 2.2.0   1 conflicts, 1 merged
  #1 ✓ merged    Uncaught DOMException: Failed to execute 'rem…  3394487-failed-to-execute-remov…
  #2 ! conflicts Metatags depending on URL cause errors for un…  yoast_seo-3110455-22.patch
                 src/EntityAnalyser.php:211: patch failed                                                       
                 
drupal/search_api_solr_multilingual 3.1.0   1 unknown
  #1 ? unknown   Drupal 10 compatibility                         Drupal-10-Compatibility-3370449…
                 drupal/search_api_solr_multilingual has no release for 11.4.6: the package blocks the upgrade, so its patches cannot be judged
                 
drupal/menu_item_extras 2.19.0   1 unknown
     ! 3.1.2 supports 11.4.6; the site requires ^2.19. Widen it to ^3.1.
  #1 ? unknown   Custom fix for client                           menu_item__extras_client_fix_1.…
                 3.1.2 supports 11.4.6; the site requires ^2.19. Widen it to ^3.1.                 

A patch that was shipped in the newest release! we can drop this patch and update, nice. We also have some problems with updating a patched module, there are no releases for Drupal 11, impossible to check the patch. We can see that the menu_item_extras has a release that supports Drupal 11, but we can't check it because the composer constraints don't allow it. And we end up with:

patches: 27 applies, 24 conflicts, 1 merged, 4 unknown
composer already applied these patches to your files

Next:  composer drupatch:reroll --target 11.4.6            writes the 24 re-rolls
       composer drupatch:reroll --target 11.4.6 --update   drops the shipped entry from composer.json

we should first do a reroll to get the 24 conflict files, resolve the problems and call reroll --update to create the conflict files, resolve what can be and remove patches that ship with the new release.

Reroll please:

The only difference with the command earlier is that we have a couple of patches dropped: 

Drupal Patch Check: 56 patches for a move from core 10.2.4 to 11.4.6

patches: 2 now apply, 21 conflicts left, 4 unknown

already in the release, dropped:
  drupal/geoip: Automated Drupal 10 compatibility fixes
    https://www.drupal.org/files/issues/2023-06-16/gepop.3.0.x-update-to-d10.patch

And now after resolving the 21 conflicts the site will be fine. Faster than reviewing the 56 patches manually. Process is the same as before, resolve conflicts, and keep calling composer drupatch:reroll --target 11.4.6 until the output is ok.

Wrapping up

Why a remote service? 

Conceptually there is no need to have the plugin send data out, it could be all resolved locally. It's not going to take a second or two though. It would need some pretty extensive processing and guards to make sure it's all safe and correct. It is much easier and a few orders of magnitude faster since all the heavy data is already available at hand on my end. For a few years now I've built an index of a lot of metadata about Drupal core and contrib, turning that data into a useful service seemed easy enough. 

TL:DR;

$ composer drupatch:check
$ composer drupatch:check --target=latest
$ composer drupatch:reroll --target=latest

You can also run this in CI, as a hook after every composer update, filter by package, or by patch, and a few more things the endpoint can do but it's a long enough post. I hope this will be useful. I know I'll be using it at least.

Help needed

Thanks for reading so far. In order to improve the tool I need more examples of real and vetted upgrades I have a handful of examples to use myself but I would need a bit more examples. I need people to supply me with the --dry-run data for the site before an upgrade and after the upgrade so they can be compared and build-in some of the special Drupal sauce in the service that can benefit everyone.

If you do not want to have the patch path submitted as part of using this tool, you can obfuscate private paths with this in your composer.json:

{ "extra": { "drupal-patch-check": { "private-paths": true } } }
# In the checkout from before the upgrade
composer drupatch:check --dry-run > /tmp/drupatch-before.json
# In the checkout from after
composer drupatch:check --dry-run > /tmp/drupatch-after.json
# One file to send
tar czf /tmp/drupatch-state.tgz -C /tmp drupatch-before.json drupatch-after.json

And send me that file through slack or any other means you're comfortable with. Thank you!