Upgrading a 4.x project to 5.x
This guide upgrades a Drupal module or theme created from a 4.x release of this template to 5.x.
5.x is a bigger step than a usual update. Since 4.19.0, the last 4.x release, the template became Eddy and moved to drevops/eddy, the scripts in .devtools/ turned into the drevops/eddy-tooling Composer package, and CircleCI support went away. The upgrade rewrites most of your build and CI files in one go, and a few of the changes need decisions only you can make.
There's a more immediate catch, too: a 4.x project can't update itself anymore. From 4.15.0, its AGENTS.md fetches the update skill from the .scaffold/ directory of the old repository, and that URL returns 404 now that the skill lives in .eddy/. The steps below get you past it.
Before you start
- Commit or stash your work in progress. The upgrade changes most of the files in the project root, and the update skill won't start while the working tree has changes, apart from untracked files in
.claude/and.idea/. - Find the edits you made to
.devtools/and, in a project created from 4.18.0 or 4.19.0, to thescripts/*-cloudflared.shhooks. The upgrade deletes both, so each edit needs a new home (see.devtools/is a package now).git log -p -- .devtools 'scripts/*-cloudflared.sh'shows every change to those files, template updates included, so look for the commits that are yours. If every commit there is a template update, your edits went in with them, and the log can't tell the 2 apart. The update skill can, as long as it knows the release your project is on: each file you changed comes up as a modify/delete conflict, and the pull request lists it. - If your project runs on CircleCI, keep its project settings at hand. Its deployment settings move to GitHub Actions (see CircleCI is gone).
- Check the PHP versions your CI tests. The
drevops/eddy-toolingpackage that replaces.devtools/requires PHP 8.3 or newer, so a job on an older PHP, such as Drupal 10 on PHP 8.2, fails at "Install the tooling" because Composer can't install the package. Raising those jobs to PHP 8.3 drops a PHP version your extension tests, so decide that before you start. The update skill lists the jobs it finds below PHP 8.3 in its pull request. - If your project is on a release from 4.15.0 to 4.18.0 and deploys from GitHub Actions, set the
DEPLOY_PROCEEDsecret to0before you start, and back to1just before you merge. Otherwise the upgrade pull request's test runs deploy your default branch to your Drupal.org repository (see Deployment). The update skill stops before it pushes when it finds aDEPLOY_REMOTEsecret, and its pull request lists what's left to do around the merge.
Upgrade with Claude Code
The update-consumer-eddy skill does most of the work. It renders the latest release and the release your project is on with your project's answers, merges the difference into your project and opens a pull request. Your own files stay as they are, and so do the changes you made to the template's files, such as the versions Renovate bumped. The skill reads the release your project is on from your last scaffold update commit. Without one, every difference between your files and the new release's comes up for a decision. It already knows about 4.x projects: it drops .devtools/ and the Cloudflare hooks, and it reads your Drupal versions from .circleci/config.yml when your project has no GitHub Actions workflow.
What it can't do is fetch itself into a 4.x project, so you do that part once, from the project root:
-
Delete the old skill directory. The
AGENTS.mdof 4.15.0 to 4.19.0 downloads the skill withcurl -sSL, which doesn't fail on an HTTP error, so the directory holds the 404 page instead of a skill.rm -rf .claude/skills/update-consumer-drupal-extension-scaffold -
Download the new skill:
mkdir -p .claude/skills/update-consumer-eddycurl -fsSL -o .claude/skills/update-consumer-eddy/SKILL.md \https://raw.githubusercontent.com/drevops/eddy/main/.eddy/skills/update-consumer-eddy/SKILL.md -
Start Claude Code in the project and run
/update-consumer-eddy, or ask Claude to run theupdate-consumer-eddyskill. If Claude Code was already running when you downloaded the skill, restart it first: a session that started before the download may not list the skill yet, and answers "Unknown skill: update-consumer-eddy".On its first run, the skill may add a few command permissions to
.claude/settings.local.jsonand ask you to restart Claude Code. Run it again after the restart. If Claude Code won't let it write that file, as in auto mode, the skill prints the permissions for you to add yourself, and carries on when the session doesn't ask before running commands anyway.Without a tag, the skill upgrades to the release with the highest version that isn't a draft or marked as a pre-release on GitHub, whenever it was published. Only that mark makes a release a pre-release here, so an alpha or a release candidate published without it counts as a regular release. On the releases page, that's the highest version without a "Pre-release" label. To upgrade to a particular release, name its tag after the command:
/update-consumer-eddy <tag>.To add Drupal 12 as well, ask for it in the same request, such as
/update-consumer-eddy add Drupal 12(see Drupal 12). -
Go through What changed while you review the pull request it opens, and make the changes the skill leaves to you: moving your
.devtools/edits and the CircleCI settings. If you asked for Drupal 12, the pull request also holds the Drupal 12 code changes, each in a commit of its own, so review those too.
The upgrade rewrites AGENTS.md, so from then on "update Eddy" works the way it does in any 5.x project.
Upgrade by hand
Follow the manual steps in Updating your extension, with these differences for a 4.x project:
- The installer is
install.php, notinit.php(init.shbefore 4.12.0). It no longer asks for a CI provider or about the Cloudflare hooks, and it reads pre-filled answers fromINSTALL_*variables instead ofDEX_*(4.19.0) orPROMPTY_*(4.14.0 to 4.18.0). Earlier releases took them as positional arguments.php install.php --helplists the variables. - Before you copy the new files in, delete whichever of these your project has:
.devtools/,.circleci/,scripts/provision-cloudflared.sh,scripts/start-cloudflared.sh,scripts/stop-cloudflared.shand.claude/skills/update-consumer-drupal-extension-scaffold/. scripts/holds your own hooks, but takescripts/eddy-toolingfrom the release. It's the installer of the tooling package, and it's new in 5.x.- Merge
composer.dev.jsonrather than overwriting it, so your own dependencies and patches survive..devtools/is a package now lists the entries 5.x adds. install.phpruns outside your repository here, so it can't read your branches from git and writes1.xinto the branch filters of.github/workflows/test.ymland.github/workflows/draft-release-notes.yml. If your project builds from other branches, list them inINSTALL_BRANCHES, such asINSTALL_BRANCHES=1.x,2.x php install.php.
Then work through What changed for the decisions the merge leaves to you.
What changed
Each change below covers what it was in 4.x, what it is in 5.x, and what your project needs.
Name and repository
In 4.x, the template was the Drupal Extension Scaffold at AlexSkrypnyk/drupal_extension_scaffold. It kept its own files in .scaffold/, and from 4.15.0 your AGENTS.md fetched the update-consumer-drupal-extension-scaffold skill from there, on the 1.x branch.
In 5.x, it's Eddy at drevops/eddy, on the main branch. Its own files live in .eddy/, which never reaches your project. The update skill is update-consumer-eddy, and the installer is install.php.
What to do:
- Delete
.claude/skills/update-consumer-drupal-extension-scaffold/. - In
AGENTS.md, replace the "Updating the scaffold" section with the one Eddy ships. It downloads the skill withcurl -fsSL, which fails on an HTTP error instead of saving the error page. - In
.gitignore, delete the.claude/skills/update-consumer-drupal-extension-scaffold/line. The.claude/*rule above it already ignores everything in.claude/exceptsettings.json, the new skill's directory included. - In
README.md, credit Eddy in the closing line instead of the Drupal Extension Scaffold.
The upgrade makes the last 3 changes for you.
.devtools/ is a package now
In 4.x, every project carried its own copy of the build, run and deploy scripts in .devtools/: assemble, start, stop, provision, deploy, browser, info and qrcode, with helpers.php behind them. The command wrappers and CI ran them as .devtools/<script>, so a fix reached your project only when you merged the next release by hand.
In 5.x, the scripts ship as the drevops/eddy-tooling Composer package, so fixes arrive through its version constraint instead. The package requires PHP 8.3 or newer (see Before you start). scripts/eddy-tooling installs the package into vendor/, and each script becomes a command in vendor/bin/: .devtools/assemble is vendor/bin/eddy-assemble, and .devtools/browser start and stop are eddy-browser-start and eddy-browser-stop.
ahoy and make run the installer before the commands that need it, and CI runs it as a step of its own. The installer skips Composer while nothing it depends on has changed, so running it every time costs next to nothing. Tooling package has the details.
What to do:
- Delete
.devtools/and addscripts/eddy-toolingfrom the release. - Add the 5.x entries to
composer.dev.json, next to your own:drevops/eddy-toolingandcweagans/composer-patchesinrequire-dev, with the constraints from the release'scomposer.dev.json.cweagans/composer-patchesinconfig.allow-plugins.extra.eddy.drupal-version, set to the newest Drupal major you build that has a stable release, such as"11". A local build uses it whenDRUPAL_VERSIONisn't set, and Drupal 11 when neither is.- The release's
drush/drushconstraint, which adds Drush 14 for Drupal 12. extra.eddy.version, set to the release you upgrade to, such as"5.0.0". The update skill reads it to find the release your project is on.- Without FunctionalJavascript tests, the Mink packages that Functional tests need in
require-dev:behat/mink,behat/mink-browserkit-driver,symfony/browser-kit,symfony/css-selectorandsymfony/dom-crawler. From 4.16.3, 4.x removed them along with those tests.
- Move each edit you made to
.devtools/:- A project-specific step, such as importing extra configuration once the site is installed, becomes a lifecycle script:
scripts/assemble-*.sh,scripts/provision-*.sh,scripts/start-*.shorscripts/stop-*.sh. - A change to what a command itself does becomes a patch for
drevops/eddy-toolingincomposer.dev.json, as Patching the package describes. The commands live in the package'ssrc/directory, such assrc/eddy-assemble, and they've changed a lot since 4.x, so write the patch against the current source instead of reusing an old diff. This is the trickiest part of the upgrade.
- A project-specific step, such as importing extra configuration once the site is installed, becomes a lifecycle script:
- Replace every
.devtools/<script>call in your own scripts, docs and CI steps withvendor/bin/eddy-<script>. Where no wrapper or CI step runs the installer first, runscripts/eddy-toolingbefore it.
The build changed in 2 ways you might notice:
- It applies the patches you declare. From 4.14.0 to 4.19.0, the build didn't install
cweagans/composer-patches. Unless your project required the plugin itself, Composer skipped every patch incomposer.jsonandcomposer.dev.jsonwithout a word. In 5.x they apply, and a patch that no longer applies fails the build (see Patching dependencies). - It pins Drupal core to 1 exact release, and resolves every other dependency with
minimum-stability: devandprefer-stable: true(see How versions are resolved).DRUPAL_VERSIONtakes values such as11,11.1.0or11@beta, but no longer@dev, because a development branch isn't a release.
start and stop changed too. In 4.x they killed whatever listened on the project's port. In 5.x they only stop the webserver that serves the project's own build/web, so the server of another project that recorded the same port keeps running, and start fails and names it instead of taking the port over (see Webserver). browser-stop treats the WebDriver backend the same way, so it leaves the browser of another project running.
Cloudflare tunnel hooks
In 4.18.0 and 4.19.0, init.php could keep 3 hooks that ran a Cloudflare quick tunnel: scripts/provision-cloudflared.sh, scripts/start-cloudflared.sh and scripts/stop-cloudflared.sh.
In 5.x, eddy-start, eddy-provision and eddy-stop run the tunnel themselves when CLOUDFLARE_TUNNEL=1 is set, in the shell or in .env (see Public tunnel). They also fix a bug in the hooks: the hooks made *.trycloudflare.com the only trusted host, so after a tunnel provision the site answered 400 on http://localhost:<port>.
What to do:
- Delete the 3 hooks. Left in place, they act on the same tunnel as the commands, and the start hook can restart it.
- Carry any edit you made to them as a patch for
drevops/eddy-tooling, the same way as an edit to.devtools/.
A project created before 4.18.0 never had these hooks.
CircleCI is gone
In 4.x, init.php asked which CI provider to use. A CircleCI project got .circleci/config.yml instead of the GitHub Actions workflows, kept its deployment key in CircleCI with the key's fingerprint in the config, and deployed every branch that passed its tests.
In 5.x, GitHub Actions is the only CI, and every project gets .github/workflows/test.yml, deploy.yml, draft-release-notes.yml and assign-author.yml.
What to do, if your project ran on CircleCI:
- Delete
.circleci/. The upgrade adds the GitHub Actions workflows. - Move the deployment settings to the repository's Settings → Secrets and variables → Actions, as Setting up deployment describes:
- The private deployment key becomes the
DEPLOY_SSH_KEYsecret. GitHub Actions doesn't need its fingerprint. DEPLOY_REMOTE,DEPLOY_USER_NAME,DEPLOY_USER_EMAILandDEPLOY_PROCEEDbecome secrets.DEPLOY_BRANCH, if you set it, becomes a repository variable.CODECOV_TOKEN, if you upload coverage to Codecov, becomes a secret.- Each
CI_*_IGNORE_FAILUREtoggle you set becomes a repository variable (see Allowing a CI step to fail).
- The private deployment key becomes the
- Check which branches deploy. GitHub Actions deploys release tags and pushes to the branches in the
pushfilter oftest.yml, which is1.xunless you change it, so a branch outside that list stops deploying. - In your branch protection rules, or the ruleset that protects your default branch, swap the
ci/circleci: <job>checks for the GitHub Actions jobs that Branch protection lists for your Drupal majors. From 4.18.0, each CircleCI job has a GitHub Actions job of the same name. - Stop building the project in CircleCI. Otherwise every push gets a failing "CircleCI Pipeline" check that can't find a configuration.
A project on GitHub Actions has nothing to do here.
Deployment
In 4.19.0, and on CircleCI, a release tag also force-pushed the tagged commit to a branch of your Drupal.org repository, so a tag behind that branch's tip rewound it. A test run that finished late, or that you re-ran, could rewind a branch the same way. Earlier releases' GitHub Actions Deploy workflow pushed whatever your default branch held when it ran, and from 4.15.0 to 4.17.0 a release tag pushed it to a branch named after the tag. The first deployment of a new branch failed, and the Deploy workflow failed on every run until you set up deployment. From 4.15.0 to 4.18.0, a pull request's successful test run deployed too, pushing your default branch to a branch of your Drupal.org repository named after the pull request's branch. In 4.18.0, a DEPLOY_BRANCH repository variable, when set, names the branch instead.
In 5.x:
- Only a push to your repository deploys. A pull request's test run never does.
- A release tag deploys only the tag and leaves your branches alone.
DEPLOY_BRANCHapplies to branch pushes only. - A branch push deploys only while its commit is still the tip of the branch. An older commit is skipped with a notice, so it never replaces a newer one.
- Deployments of the same branch wait for each other instead of running side by side, so 2 of them can't push out of order.
- The first deployment of a branch creates it.
- Until the
DEPLOY_REMOTEsecret is set, theDeployworkflow skips its steps and reports a notice instead of failing.
What to do:
- If your release process relied on a tag push to update a branch, push the branch as well.
- On a release from 4.15.0 to 4.18.0, the upgrade pull request still deploys the 4.x way described above. GitHub runs a
workflow_runworkflow, such asDeploy, from the copy on your default branch, so the 4.xdeploy.ymlstays in charge until you merge. To skip that, set theDEPLOY_PROCEEDsecret to0before the pull request opens, and back to1just before you merge. Otherwise, once you've merged, delete every branch named after a pull request from your Drupal.org repository, such as withgit push <remote> --delete <branch>, including any that earlier pull requests left there. - On a release from 4.15.0 to 4.17.0, also delete every branch named after a release tag from your Drupal.org repository. Those releases created one for each tag they deployed.
Deployment describes the new behavior in full.
Drupal 12
5.x can test your extension on Drupal 12 next to Drupal 10 and 11. It's opt-in: the upgrade keeps the Drupal majors your CI tests today. To add Drupal 12, select 12 when install.php asks for the Drupal versions, or ask for it when you run the update skill, such as /update-consumer-eddy add Drupal 12. Once you're on 5.x, the skill can add it later the same way, even when there's no newer release to update to.
Drupal 12 brings 5 CI jobs (lint-d12, test-php-min-d12-stable, test-php-max-d12-stable, test-php-min-d12-legacy and test-php-max-d12-canary) and a README badge. It also needs 3 changes in your own code, which no template merge makes. When you add Drupal 12 with the update skill, the skill makes them after the merge, each in a commit of its own, so review them in the pull request. Otherwise make them yourself:
- Add
^12tocore_version_requirementin every*.info.ymlyour project has, not only the main one. A submodule your tests enable, such as an example module, needs it too: without it, every Drupal 12 Functional and FunctionalJavascript test that enables the submodule fails at install. - If your FunctionalJavascript tests extend the base class the template generated, declare
string|falseas the return type of itsgetMinkDriverArgs(). Drupal 12 declares that type on the parent method, so an override without it is a fatal error, and Drupal 10 and 11 accept the typed override too. The upgrade never touchestests/, so bring 2 more things in that class in line with the 5.x copy while you're there, whether or not you add Drupal 12: drop theCIRCLECIcheck insetUp()but keep the base URL setup inside it, and, from 4.18.0, change.devtools/browsertoeddy-browser-startin a comment. - Pair
@groupwith#[Group]and@dataProviderwith#[DataProvider], the way the example tests do. Drupal 10 runs PHPUnit 9.6, which reads only the annotations, and Drupal 12 runs PHPUnit 12, which reads only the attributes.@coversand@coversDefaultClassonly narrow code coverage, so the example tests leave them without an attribute. Kernel, Functional and FunctionalJavascript test classes also need#[RunTestsInSeparateProcesses], which Drupal 12 requires and the example tests have carried since 4.18.0.
Drupal 12 requires PHP 8.5. CI runs every Drupal 12 job on PHP 8.5, and adding Drupal 12 leaves extra.eddy.drupal-version on the newest major you test that has a stable release, so a plain ahoy build keeps working on the PHP you have. To build Drupal 12 locally, run DRUPAL_VERSION=12 ahoy build on PHP 8.5. On an older PHP, the build stops before it changes anything and says which PHP the release needs.
Your own tests can run into more of what changed in Drupal 12. These are the breaks a contrib module is most likely to meet when it adds Drupal 12 next to 10 and 11, and each fix works on all 3 majors:
- Update path dumps. Drupal 12 ships only
drupal-11.3.0.bare.standard.php.gzanddrupal-11.3.0.filled.standard.php.gzincore/modules/system/tests/fixtures/update/. Drupal 10.6 and 11.0 to 11.3 don't have the 11.3.0 dumps, and 11.4 has them next to thedrupal-10.3.0.*ones that Drupal 12 dropped. An update path test that runs on every major sets$databaseDumpFilesto the 11.3.0 dump when its file exists, and to the 10.3.0 one otherwise. - Typed
KernelTestBaseproperties. Drupal 12 declares$strictConfigSchemaasbool, along with properties such as$containerand$siteDirectorythat Drupal 10 and 11 leave untyped. PHP rejects a redeclaration whose type differs from the parent's, so no redeclaration works on all 3 majors. Set the value insetUp()beforeparent::setUp()instead, such as$this->strictConfigSchema = FALSE;. text_with_summary. The field type, itstext_textarea_with_summarywidget and itstext_summary_or_trimmedformatter moved from core to the contribdrupal/text_with_summarymodule, andnode_add_body_field()is gone. That module supports only Drupal 11.5 and 12, so a module that needs it on Drupal 12 can't require it on Drupal 10. When only your tests use the field type, a lifecycle script can add the contrib module to the builds whose core no longer has it.- Traits in
#[CoversClass]. PHPUnit 12 rejects a trait named in#[CoversClass]with a warning that it "is not a valid target for code coverage", and the template'sfailOnWarning="true"turns that warning into a failed job even when every test passes. Name a trait with#[CoversTrait]instead: PHPUnit 11 has it, PHPUnit 10.5 skips attribute classes it doesn't know, and PHPUnit 9 ignores attributes. - The
filtermodule in kernel tests.TextItemBase::applyDefaultValue()now asks thefiltermodule for the fallback text format, so a kernel test that creates an entity with a text field needsfilterin$modules: kernel tests don't enable dependencies for you. Core's11.5.xbranch has the same change, so the Drupal 11canaryjob meets it once 11.5 pre-releases are out. - Mocks without expectations. PHPUnit 12.5, which Drupal 12 builds run, reports a notice for every mock object a test creates without an expectation, and
expects($this->any())doesn't count as one. The template'sphpunit.xmlonly reports the notices, but a module with hundreds of such mocks gets hundreds of notices in every run, and Drupal 12 core's ownphpunit.xml.distfails the run on them. Create test doubles that only return values withcreateStub(), which every PHPUnit version that Drupal 10 to 12 runs provides. A test class that keeps such mocks on purpose can opt out with#[AllowMockObjectsWithoutExpectations], which older PHPUnit versions ignore.
Check that the dependencies in your composer.json support Drupal 12 too, and add the new jobs to your branch protection rules or ruleset (see Branch protection). Continuous integration explains how the Drupal 12 jobs follow Drupal releases.
Everything else
The rest of the template moved on as well, and an upgrade merges these files like any template update:
.ahoy.ymlandMakefilecallvendor/bin/eddy-*and install the tooling first. When you keep FunctionalJavascript tests,testnow starts the browser before PHPUnit, so it needs Chrome, or Docker withWEBDRIVER_BACKEND=selenium.test-unit,test-kernelandtest-functionaldon't.resetstops only the project's own webserver, and its browser when you keep FunctionalJavascript tests, rather than every PHP process you're running, and it also removesvendor/, which is how a local checkout picks up a newer patch release of the tooling.make startdiscovers a free port the wayahoy startdoes: the 4.xMakefileexportedWEBSERVER_PORT ?= 8000, so it always took port 8000..github/workflows/test.ymlinstalls the tooling and callsvendor/bin/eddy-*. Its Composer cache works now: the 4.x cache step pointed at a directory Composer never wrote to, so it never stored anything..github/workflows/deploy.ymlinstalls the tooling and callsvendor/bin/eddy-deploy(see Deployment).- Since 4.18.0,
phpunit.xml,phpunit.d10.xml,phpcs.xml,phpstan.neonandjest.config.jschanged only in their comments, andrector.phpdidn't change at all, so keep any settings you added to them. The exceptions are theMINK_DRIVER_ARGS_WEBDRIVERentries in both PHPUnit files. 4.19.0 dropped theirforce="true": in a project on 4.18.0 or earlier, a value you export now overrides the one in the file instead of the other way round. A project without FunctionalJavascript tests loses the entries altogether, since only those tests read them. .cspell.jsonadds the wordcweagans, whichcomposer.dev.jsonnow contains, dropskillallandpkill, which nothing uses any more, drops theignorePathsthat pointed into.scaffold/, and drops the words of the tools you don't keep, such aslullabotwithout FunctionalJavascript tests. It also swapsautologoutforlinkit, the example module the template'scomposer.jsonsuggests, so keepautologoutif your project still uses that word. Merge it with your own words.AGENTS.mddescribes the tooling commands,vendor/andscripts/eddy-toolingin place of.devtools/, drops CircleCI, and carries the new "Updating the scaffold" section.CONTRIBUTING.mddocuments only the command wrappers you keep, or thevendor/bin/eddy-*commands when you keep neither. Carry your own notes over from the old copy.README.mddrops the CircleCI badge, shows Drupal badges only for the majors you test, and credits Eddy..gitattributesdrops its.circleciand.devtoolslines.
After the upgrade
Before you merge the upgrade:
- Build, lint and test the project with
ahoy build,ahoy lintandahoy test, or the samemakecommands, which install the tooling intovendor/first. Without a wrapper, runscripts/eddy-tooling, then the commands that Building the site, Coding standards and Testing show under No wrapper. - Check that every CI job passes on the pull request.
- Check that the branch filters in
.github/workflows/test.ymland.github/workflows/draft-release-notes.ymllist the branches you build from. - Check that your branch protection rules, or the ruleset that protects your default branch, require the jobs in Branch protection for the Drupal majors you test, and no
ci/circleci: <job>checks. - If you set the
DEPLOY_PROCEEDsecret to0for the upgrade, set it back to1.
After you merge it, check that the next Deploy run reaches your Drupal.org repository, or reports a notice that says why it didn't. If your project was on a release from 4.15.0 to 4.18.0, also delete the branches that pull requests left in your Drupal.org repository, and from 4.15.0 to 4.17.0 the branches named after release tags (see Deployment).