Sorry, you need to enable JavaScript to visit this website.

Vortex 1.41.0 - Lumen: what's in this release

Last updated: 1 Sep 2026

Vortex 1.41.0 - Lumen hands work back to the ecosystem: core's mail collector keeps tests away from real inboxes, security scanning stops blocking builds, large database dumps import again, and the front end moves to npm and Drupal core's ESLint rules.

Vortex1.41.0 is out, and 2 things run through it. The first is handing work back to the ecosystem: several pieces the template used to maintain itself now belong to Drupal core or to a package that is actively looked after, so less of the stack is ours and more of it behaves the way a Drupal developer already expects. The second is making the day-to-day surface of a project harder to get wrong, and easier to extend on the occasions you do want to change it.

Environment

Improved large database handling

Importing a large database dump died partway through with ERROR 1114 (HY000): The table '...' is full, which is one of those errors that sends you looking for a full disk when the disk is fine. The real constraint is InnoDB's redo log. At the 128MB default it can be exhausted by a single large import, and on a site whose dump runs to 1.5GB or more that happens reliably. The error points nowhere useful, so hitting it turns a working local environment into a session of guessing at MySQL settings.

The fix should have been an innodb_redo_log_capacity setting in the shipped my.cnf, except that file was never reaching the server. .docker/database.dockerfile copied it to /etc/my.cnf.d/server.cnf, and neither image Vortex runs for the database service reads that path, so the COPY had been quietly creating a directory nothing ever looks at.

The copy now goes to /etc/mysql/conf.d/server.cnf, which both images do read, with the capacity set to 1GB.

BEFORE
  my.cnf ──COPY──▶ /etc/my.cnf.d/server.cnf
                   └─ a directory the COPY creates and nothing reads
  the image reads  /etc/mysql/conf.d  ──▶  empty
  redo log stays at the 128MB default
                   └─ ERROR 1114 partway through a 1.5GB dump

AFTER
  my.cnf ──COPY──▶ /etc/mysql/conf.d/server.cnf
                   └─ read by both the MySQL and MariaDB images
  redo log capacity 1GB  ──▶  the dump imports

It applies on your next container rebuild. It does cost about 900MB of disk per database container, because MySQL 8.0.30+ preallocates the redo log up front. To lower the capacity, edit my.cnf under .docker/config/database/, which stays yours to change.

The Provision page(Opens in a new tab/window) documents the database operations around this, including import and export.

Excluded cache data from database dumps

Exports included every cache table's rows, which Drupal rebuilds on demand anyway. That data was pure weight in every dump, every CI database cache, and every Lagoon pre-deployment backup.

The export now passes --structure-tables-list to drush sql:dump(Opens in a new tab/window), so cache tables keep their CREATE TABLE and lose their rows. It's a single change in the one shared export path, so it reaches ahoy export-db, both CI providers and the Lagoon backup without duplicating shell logic. It also doesn't mutate the database being exported, which matters when that database is a live environment.

To strip a different set of tables, set VORTEX_EXPORT_DB_FILE_STRUCTURE_TABLES to a comma-separated list of table names, each of which may use *. It defaults to cache*; to go back to exporting every table's data, set it empty. The default covers cache tables only, so watchdog, sessions, flood and semaphore keep theirs. watchdog in particular is exactly what you want in a pre-deployment backup after a bad deploy.

Raised the default Drush memory limit

The shipped Drush memory_limit was 512M, which long-running commands on a large site outgrow. Config sync and database imports are the usual casualties, and both fail late enough to waste a few minutes before they do.

It's 1G now, and it applies to Drush commands only. To change it for your project, edit drush/php-ini/drush.ini and rebuild the CLI container. To raise it for a single command, run ahoy cli php -d memory_limit=2G vendor/bin/drush <command>.

One thing worth knowing before you raise it much further: past 1G, an out-of-memory error usually points at a memory leak in custom code rather than a limit set too low.

Shipped Drush configuration is covered on the Drush page(Opens in a new tab/window).

Skipped the database image fetch when it is already on the host

Every build pulled the database container image, whether or not the host already had that exact image sitting in its local Docker store.

The fetch is now skipped when the image is already present.

It applies automatically, and the saving lands on repeat builds: the first build of the day still pulls, and every one after it starts straight away.

Turned the ignore files into deny lists

.gitignore, .dockerignore and .gitignore.artifact were all allow lists: ignore everything, then explicitly un-ignore a curated set of paths. That meant every new legitimate file silently vanished from git, or the container image, or the deployment artifact, until someone noticed and whitelisted it. New Drupal scaffold files arriving with a core update simply disappeared.

All 3 are deny lists now, excluding only what's actually harmful: VCS internals, secrets, local overrides, generated content, database dumps, caches and test artifacts. What ends up deployed is unchanged.

BEFORE  allow list
  ignore everything under web/
  then un-ignore roughly 25 named paths
  └─ a new legitimate file is invisible until someone
     remembers to whitelist it

AFTER  deny list
  track everything
  then deny what is harmful: secrets, generated directories,
  database dumps, caches, local overrides, test artifacts
  └─ a new legitimate file shows up in git status

The default flips from "invisible unless whitelisted" to "included unless denied", which is the safer failure mode by a wide margin. A file you forgot to deny is a file you can see and delete. A file that was silently never committed is a production incident. As a bonus, excluding development artifacts from the Docker build context dropped the Vortex development build context from roughly 92MB to a few MB.

It applies on update. If you added your own un-ignore entries to any of these files, translate them into deny form when you resolve the merge conflict. Then run git status, since files that were silently ignored before may now show up as untracked.

The Environment page(Opens in a new tab/window) explains which files are ignored and why.

Tracked .claude/skills/ by default

The .claude/ directory was ignored wholesale apart from one settings file. That was right when everything in there was machine-local state, but agent skills are not: a skill your team writes for the project is shared knowledge and belongs in the repository, and being ignored by default meant it silently never got committed.

.claude/skills/ is un-ignored now, while the rest of .claude/ stays ignored.

Commit your project skills under .claude/skills/ and they travel with the repository. Machine-local Claude state is still ignored, so nothing personal follows them.

The AI page(Opens in a new tab/window) covers the shipped agent configuration and how to add project skills.

Provision

Added search indexing to provisioning

A freshly provisioned local site came up with an empty search index, so search looked broken until someone remembered to index by hand. It reads as a misconfiguration, so the first person to hit it goes looking in the wrong place.

Provisioning now re-indexes the search backend once it has finished building the site.

It runs automatically on local, CI, dev and stage environments, and never on production. To skip it, set DRUPAL_SEARCH_INDEX_SKIP=1.

Provision scripts, their order and their per-script conditions are documented on the Provision page(Opens in a new tab/window).

Disabled Solr during migrations

When a migration ran, Search API Solr(Opens in a new tab/window), which feeds Drupal's Search API into a Solr server, kept indexing throughout it, so 2 heavy processes competed for the same resources at the worst possible moment.

The Solr server is now disabled for the duration of a migration and re-enabled afterwards.

The re-index step above runs later in the same provision, after the migration finishes, so the environment ends up fully indexed with the migrated content.

It is on by default. To leave the server running through migrations instead, set DRUPAL_MIGRATION_SEARCH_DISABLE=0.

Migration setup and its variables are covered on the Migrations page(Opens in a new tab/window).

Anchored environment name matching in provision scripts

Provision scripts compared environment names loosely, so a name that merely contained another could match the wrong branch of a condition. An environment called development could satisfy a check written for dev.

The comparisons are anchored now, so a name matches only itself.

It applies automatically on the next provision. If you named an environment so that it contained one of the standard names, this is the release where it starts behaving the way you expected.

Modules

Replaced Admin Toolbar with core Navigation

Drupal core has been building its own replacement for the admin Toolbar. The Navigation module(Opens in a new tab/window) is a left sidebar rather than a top bar, it went stable in Drupal 11.3, and core treats it as the strategic successor to Toolbar rather than as a competing option. That changes the maths for a template that had been shipping a contrib module purely to make Toolbar usable.

Until now the standard install profile enabled core's toolbar, and Vortex installed drupal/admin_toolbar(Opens in a new tab/window), the contrib module that expands that toolbar into full dropdown menus, on top of it. Running both meant 2 admin UI systems active at once, which is a documented conflict that breaks contextual edit links.

Core Navigation is the default now. Provisioning installs navigation and uninstalls toolbar, but only when toolbar is actually enabled, so a re-provision is a no-op while a genuine uninstall failure still aborts. The contrib slot that held Admin Toolbar is filled by navigation_extra_tools(Opens in a new tab/window), which puts the cache-clear, cron and database-update shortcuts back in the sidebar.

So the admin interface moves from a dependency we carry to one core maintains, and the dual-admin-UI conflict goes away.

This one needs a deliberate step on existing sites, and the ordering matters: uninstall admin_toolbarbefore you deploy the release that drops the package. Otherwise drush deploy aborts with The module admin_toolbar does not exist. If your project genuinely depends on Admin Toolbar, add it to your own composer.json and it keeps working.

Every shipped contributed module is listed on the Contributed modules page(Opens in a new tab/window).

Added generated content and testmode to development environments

Placeholder content was something you set up yourself, and testmode was a module you remembered to install, or didn't.

generated_content(Opens in a new tab/window), which builds placeholder nodes, users and files from plugin classes you define, is now wired into the development modules provision step, and testmode(Opens in a new tab/window), which flags a site as being under test so configuration and behaviour can branch on it, is installed from the same place.

Both run automatically in development environments. To skip the content generation, set DRUPAL_GENERATED_CONTENT_SKIP=1.

Both have their own pages: Generated content(Opens in a new tab/window) and Testmode(Opens in a new tab/window).

Updated the shipped contrib modules and packages

The shipped dependency set moved as well. These arrive with the template update, so the notes below are so you know what you are getting rather than anything you need to act on. Each links to its upstream release page.

Drupal core(Opens in a new tab/window) goes from ~11.4.1 to ~11.4.5. These are patch releases, and the range carries several security-relevant fixes: an HTML injection through the AJAX page state parameter, SQL injection protections for the deprecated PostgreSQL query classes, an insecure direct object reference in private file uploads, and an AJAX trusted URL bypass via redirects. Twig moves to 3.28 alongside them. This is the single most important item in this list.

Stage File Proxy(Opens in a new tab/window) goes from ^3.1.6 to ^4.0.0, a major. The module serves files from a remote environment so you do not have to download production files locally. The 4.x branch narrows supported core to ^11.3 || ^12.0, fixes image style generation for filenames containing multiple dots, and moves redirect URL generation onto the public stream wrapper. If you pin core below 11.3 this is the release that stops you.

Search API Solr(Opens in a new tab/window) goes from ^4.3.10 to ^4.4.0. Functionally this release is identical to 4.3.13; the version bump exists to mark the branch as dropping Drupal 10 and anything below 11.3. Nothing changes in how it behaves.

Navigation Extra Tools(Opens in a new tab/window) at ^1.3.2 is new, and Admin Toolbar(Opens in a new tab/window) is gone. That swap is covered in its own section above.

Drupal Helpers(Opens in a new tab/window) (^2.0.1 to ^2.1.1) and Generated Content(Opens in a new tab/window) (^2.0.1 to ^2.1.1) both take a minor. Neither project published substantive release notes for the range beyond coding-standards and tooling updates, so the links go to their release listings rather than a summary that would be guesswork.

Testmode(Opens in a new tab/window) (^2.7.1 to ^2.7.2), SDC Devel(Opens in a new tab/window) (^1.0.2 to ^1.0.3) and Drush(Opens in a new tab/window) (^13.7.4 to ^13.7.6) are patch bumps with no behaviour change to speak of.

behat-screenshot(Opens in a new tab/window) goes from ^2.4.1 to ^2.6.0, and this range does more than the version numbers suggest. 2.4.0 added animated GIF recording assembled from per-step screenshots, so a failing scenario can be watched rather than reconstructed from stills. 2.5.0 then made that practical: frames encode at their own size instead of being padded to match the tallest one, which took a 100-step scenario's GIF assembly from 67.6 seconds down to 3.58. 2.6.0 adds the control that lets Vortex skip animation in CI, through either a @screenshots:animated:skip tag or the BEHAT_SCREENSHOT_ANIMATION_SKIP variable. Two changes need checking on your side: on_failed and purge now validate strictly as booleans and reject the string 'true' they used to accept, and step names containing tokens such as {url} now resolve in screenshot filenames rather than appearing literally.

Drupal Rector(Opens in a new tab/window) goes from ^0.21.2 to ^1.1.2, its first stable major, which is what allows the composer-based rule set described above.

PHPCS Standard(Opens in a new tab/window) reaches ^1.0.0 and Coder(Opens in a new tab/window) drops its alpha constraint, so the PHP linting stack sits on stable releases throughout. softcreatr/jsonpath is new, pulled in because the behat-steps JsonTrait needs it. PHPStan(Opens in a new tab/window), phpstan-drupal(Opens in a new tab/window), PHPUnit(Opens in a new tab/window) and Twig CS Fixer(Opens in a new tab/window) all take routine patch and minor bumps.

Themes and front end

Switched the front end from Yarn to npm

Everything the template shipped to consumers ran on Yarn 1: ahoy targets, both CI providers, the theme build stage in the Dockerfile, and the installer's tool handling.

All of that is npm now. Root and theme yarn.lock become package-lock.json, and engines.yarn becomes engines.npm.

BEFORE                             AFTER
  yarn.lock                          package-lock.json
  engines.yarn                       engines.npm

  ahoy fei      yarn install         ahoy fei      npm ci
  ahoy fe       yarn --cwd run       ahoy fe       npm --prefix run
  ahoy lint-fe  yarn run lint        ahoy lint-fe  npm run lint
  ahoy test-js  yarn test            ahoy test-js  npm test

The ahoy commands are identical on both sides. Only what they call underneath moved.

The reason is adoption. Yarn saw far less uptake across consumer projects than npm, so the template was asking teams to install a second package manager for no benefit they were feeling.

The ahoy commands keep their names, so ahoy fei, ahoy fe, ahoy lint-fe and ahoy test-js all still work. The lock files are the part you have to handle yourself: delete yarn.lock from the repository root and from your custom theme, run npm install in both to generate package-lock.json, and commit the result. Any custom script or CI step of your own that calls yarn needs to call npm instead. One quirk worth knowing while you do that: npm test <arg> doesn't forward arguments the way yarn test <arg> did, so those calls need a -- separator.

Theme structure and the front-end build are covered on the Themes page(Opens in a new tab/window).

Code quality

Adopted Drupal core's ESLint configuration

ESLint 8 reached end of life in October 2024, and its final release is marked deprecated on npm. Upgrading wasn't possible, because the ruleset was built on eslint-config-airbnb-base, which has had no release since 2022 and declares support for eslint ^7 || ^8. One abandoned package was holding the whole front-end toolchain on a dead major, and 6 of the 9 deprecation notices npm printed on every single build came from inside ESLint 8's own dependency tree.

Drupal core hit the same wall and has already worked out its answer: drop eslint-config-airbnb-base for ESLint's own recommended rules, and move to flat config(Opens in a new tab/window). Vortex now adopts that configuration rather than inventing a third destination, so the template and core stay comparable as core lands it. .eslintrc.json and .eslintignore are replaced by eslint.config.mjs at the repository root, in the custom theme and in the documentation site, and ESLint moves to 9.

Your front-end developers now lint against the same rules they already know from core, and the deprecation noise on every build is gone.

Custom ESLint rules of your own need to move to the flat config format. There are 3 deliberate departures from core worth knowing about, because Vortex treats warnings as failures where core tolerates them: no-console is an error, JSDoc blocks may keep 1 line before the first tag as the Drupal docblock style requires, and require-jsdoc is off so it doesn't start demanding a docblock on every function you write.

The ESLint page(Opens in a new tab/window) covers the configuration and the Prettier integration alongside it.

Widened the Prettier line limit

Both shipped Prettier configs were copied verbatim from Drupal core's and wrapped at printWidth: 80. That put the template at odds with its own documented house style, which is single-line code with no character limit and comments wrapped at 80 to 120, so ahoy lint-fix would break up lines the style guide said to leave alone.

printWidth is 160 now, with jsdocPrintWidth: 80 so JSDoc blocks keep wrapping at the narrower width. .editorconfig gained a [*.js] section at 160 to match, so editors and Prettier agree.

To change it for your project, edit .prettierrc.json at the root and in your custom theme. Expect ahoy lint-fix to reformat a fair amount of JavaScript on the first run after updating: it's whitespace-only line joining, with no behaviour change.

Hardened the Rector version constraint against false negatives

Rector(Opens in a new tab/window), which rewrites deprecated code to its modern equivalent for you, had been doing its job perfectly well right up until 2.6.5 landed, at which point it silently stopped applying every Drupal deprecation rule in rector.php.

Two independent upstream bugs combined to do it. A change in Rector itself turned withSetProviders() into a no-op, so the Drupal set provider was never consulted, and the documented replacement needs a constant that only exists in an unmerged change to drupal-rector(Opens in a new tab/window), the package supplying Rector's Drupal-specific rules, which Rector 2.6.5 then mis-scopes anyway. The result was 0 Drupal rules loaded, exit code 0, [OK] Rector is done! printed, and a green build.

That reaches further than our own repository, because the constraint was ^2.6.4. Any project running composer update after 2.6.5 was published would have picked it up and lost its Drupal deprecation coverage, with nothing in the output to say so.

So the constraint is tightened from ^2.6.4 to >=2.6.4 <2.6.5, holding the toolchain at the last release where the documented configuration actually works and keeping the broken one out until upstream fixes it. Hand-rolled autoload paths and file extensions came out of rector.php at the same time, since the Drupal sets supply both.

BEFORE  once 2.6.5 was published
  composer.json   "rector/rector": "^2.6.4"
    └─ resolves to 2.6.5
       └─ withSetProviders() is a no-op upstream
          └─ the Drupal set provider is never consulted
             └─ 0 Drupal rules loaded
                └─ exit 0, "[OK] Rector is done!", green build

AFTER
  composer.json   "rector/rector": ">=2.6.4 <2.6.5"
    └─ resolves to 2.6.4
       └─ the Drupal sets register
          └─ deprecations are rewritten again, and a canary
             fails loudly if the sets ever stop loading

A canary sits alongside the pin: a class carrying a known Drupal deprecation that lint is required to catch. If the rule sets stop loading again, for this reason or another, the build fails instead of reporting a pass over a codebase nothing checked.

The fix applies automatically. If you have a custom rector.php that names a versioned DrupalSetList constant, update it, since the rule set is derived from your installed core version now rather than a pinned set. The version hold is temporary and lifts once the upstream fixes land.

The Rector page(Opens in a new tab/window) covers the rule sets and the skip list.

Pinned Hadolint and narrowed its ignore rules

Hadolint, the Dockerfile linter, ran from an unpinned image and carried file-wide ignore rules. An unpinned linter lets a new upstream release fail your build on a commit that changed nothing, and a file-wide ignore switches a rule off everywhere rather than at the one line that needs it.

The image is pinned to a fixed version now, and each suppression sits on the line it applies to, with a reason.

It applies automatically. If you added blanket ignores to a Dockerfile of your own, move them onto the specific lines so the rule keeps working across the rest of the file.

The Hadolint page(Opens in a new tab/window) covers the linter and its configuration.

Testing

Improved mail handling in automated tests

Non-production environments run on a database pulled down from production, so the user table is full of real customer addresses. Vortex's first line of defence has always been sanitization: provisioning runs drush sql:sanitize, which rewrites every account's email to user+%uid@localhost and randomises passwords, so after a normal build there is nothing real left to send to.

That covers the common case, not every case. Sanitization is skipped automatically when you re-provision and keep an existing database, and VORTEX_PROVISION_SANITIZE_DB_SKIP=1 turns it off outright. Whenever it doesn't run, the layers behind it are all that stand between a test suite and a few thousand real inboxes. reroute_email(Opens in a new tab/window), which redirects every outgoing message to a single mailbox, covers dev and custom environments. ci was covered by a custom $settings['suspend_mail_send'] flag, and that was the weak link: it lived in the scaffold module consumers are invited to edit, so deleting the hook silently switched CI mail back on, and it was undocumented, so nobody knew it was load-bearing.

Testing email was the other half of the problem. The flag stopped delivery outright, so in ci there was nothing left for a test to inspect. And anywhere rerouting is on, every message arrives at the rerouting address rather than its intended recipient, so a test asserting on who was emailed is asserting on the wrong thing. Between the two, there was no environment where you could check that the right person gets the right message.

ci now has 2 independent layers instead. Drupal core's test_mail_collector(Opens in a new tab/window), the mail plugin core uses for its own tests, is set through a settings override, so it stores each message rather than sending it and nothing inside the site can switch it off. And SSMTP_MAILHUB points at 127.0.0.1:1, a closed port, so the transport can't relay even for a module that bypasses the collector by routing through interface.<module>, which is exactly what Symfony Mailer(Opens in a new tab/window) and Mail System(Opens in a new tab/window) do.

BEFORE
  provisioning  ──▶  drush sql:sanitize
                     └─ no real addresses left, unless sanitization
                        was skipped for a preserved database
  dev, custom   ──▶  reroute_email
  ci            ──▶  suspend_mail_send
                     └─ a hook in editable scaffold code; delete it
                        and CI mail flows again

AFTER
  provisioning  ──▶  drush sql:sanitize                  unchanged
  dev, custom   ──▶  reroute_email                       unchanged
  ci            ──▶  test_mail_collector   settings override,
                │                          cannot be turned off
                │                          from inside the site
                └▶  SSMTP_MAILHUB          closed port, no mail
                                           plugin can relay past it

To assert on captured mail, tag the scenario @email and use the EmailTrait steps from behat-steps(Opens in a new tab/window), our library of ready-made Behat step definitions. Rerouting is off in ci, so each stored message keeps its real intended recipient and you can assert on who it was addressed to. Untagged scenarios are still covered by the collector.

The Behat page(Opens in a new tab/window) has an Email section covering the @email tag, the collector and the transport backstop.

Wired every behat-steps trait into FeatureContext

behat-steps(Opens in a new tab/window) is a library of ready-made Behat step definitions, and the shipped FeatureContext pulls its traits in so the steps are available without you writing them. Only some of the traits were wired up, so steps the library already provided sat unavailable unless you went and added the trait yourself.

All of them are wired in now, and the library moves from 3.11.x to 3.14.x. That series is a substantial one: a Selenium-less headless Chrome driver for JavaScript steps, a ConfigTrait for asserting on configuration values, a DiagnosticsTrait that reports the URL, HTTP status and console errors when a scenario fails, a CommandTrait for running and asserting on shell commands, a JsonTrait with JSONPath and schema support, and a BigPipeTrait that waits for BigPipe placeholders so JavaScript scenarios stop racing them.

The steps are available on your next composer update, with no wiring on your side. The library did make breaking changes across that range, so check your existing scenarios: entity cleanup tags changed from @behat-steps-skip:mediaAfterScenario to @behat-steps-entity-cleanup-skip:media, the watchdog skip tag became @behat-steps-skip:watchdogAfterStep, redirectAssertModuleEnabled() was removed in favour of helperAssertModuleEnabled(), and a [relative:...] token that does not parse is now matched literally rather than silently accepted.

Available steps, profiles and reports are documented on the Behat page(Opens in a new tab/window).

Skipped animated Behat screenshots in CI

A failing Behat scenario captured both a still screenshot and an animated recording. The animation is genuinely useful on a local run, where you are watching a specific failure. In CI it costs time on every failure while telling you very little the still does not.

Animated capture is off in CI now, and the still screenshot is unchanged.

Local runs keep both, so the debugging experience where you actually need it is unaffected.

Covered ENVIRONMENT_TYPE in test isolation

Tests inherit their environment from whatever the surrounding shell and container happen to hold. ENVIRONMENT_TYPE was not part of what the test setup isolated, so a value left over from an earlier command could leak into a test run and send code down the wrong environment branch.

It is now covered by both test isolation and the container runtime, so each run starts from a known value.

It applies automatically. If you have ever had a test pass locally and fail in CI for reasons that made no sense, this is one of the mechanisms that could have caused it.

Visual regression

Enabled visual regression outside pull requests

Visual regression only ran against pull requests, so a branch you wanted to check without opening a PR first had no way to get a run.

To cover branches outside pull requests, set the VR_DIFFY_BRANCHES repository variable to the branches you want included.

One rename to make while you are in there: the variable that excludes branches from PR runs is VR_DIFFY_PR_SKIP_BRANCHES now, previously VR_DIFFY_AUTO_BRANCHES, with its default unchanged at deps/*. It lives in your CI provider's settings rather than in the codebase, so an update won't rename it for you.

The Visual regression page(Opens in a new tab/window) covers the full configuration.

Published the visual regression report to the run summary

The visual regression result was an artifact you went looking for after the fact, which meant most runs nobody looked.

It is published into the GitHub Actions workflow run summary now, so the result is on the page you already have open when you check a run.

It applies automatically to GitHub Actions projects.

Continuous integration

Simplified parallel test control in CI

Vortex splits its test suite across parallel CI containers, and each tool needs to know whether it should run on this particular container. That knowledge used to be copy-pasted into every step. GitHub Actions repeated matrix.instance == 0 || strategy.job-total == 1 across 8 step conditions; CircleCI repeated a CIRCLE_NODE_TOTAL/CIRCLE_NODE_INDEX shell comparison across 7. There was no shared vocabulary, just the same comparison written out 15 times against 2 different provider-native variable sets.

Each tool's role is now declared once, at the top of the test job, and every step reads only its own flag. That brings a set of new VORTEX_CI_* variables: VORTEX_CI_RUNNER_INDEX and VORTEX_CI_RUNNER_TOTAL as a provider-neutral way to ask "which container am I, and how many are there", plus one VORTEX_CI_IS_<TOOL>_RUNNER flag per tool. A companion change adds VORTEX_CI_BEHAT_PROFILE_OFFSET, which decouples the Behat profile number from the container index so Behat containers no longer have to start at 0.

BEFORE  one condition, copy-pasted 15 times
  GitHub Actions  8 × if: matrix.instance == 0 || job-total == 1
  CircleCI        7 × [ CIRCLE_NODE_TOTAL -gt 1 ] && [ ... ]
  CircleCI Jest   no guard at all, so it ran on every container

AFTER  roles declared once, each step reads its own flag
  test job
   ├─ VORTEX_CI_RUNNER_INDEX / _TOTAL
   ├─ VORTEX_CI_IS_JEST_RUNNER       ──▶  Jest
   ├─ VORTEX_CI_IS_PHPUNIT_RUNNER    ──▶  PHPUnit
   ├─ VORTEX_CI_IS_SDC_DEVEL_RUNNER  ──▶  SDC validation
   └─ VORTEX_CI_IS_BEHAT_RUNNER      ──▶  Behat, profile p<index>

Deduplicating the condition immediately surfaced 3 bugs it had been hiding. CircleCI's Jest step had no runner guard at all, so Jest ran on every parallel container while GitHub Actions correctly gated it to one. The parallelism docs told you to scale up containers and tag scenarios @p2/@p3 without mentioning that behat.yml needs matching profile blocks, so following the docs as written produced profile 'p2' does not exist. And both provider pages claimed the first container ran linting, which was wrong on both, since linting is its own job.

Default behaviour is unchanged, so there's nothing to do if you're happy with it. What follows is for when you want to wire in a tool of your own, or run more containers than the 2 that ship.

A role is 1 line in the test job's env: block on GitHub Actions:

env:
  VORTEX_CI_RUNNER_INDEX: ${{ strategy.job-index }}
  VORTEX_CI_RUNNER_TOTAL: ${{ strategy.job-total }}
  VORTEX_CI_IS_PHPUNIT_RUNNER: ${{ matrix.instance == 0 || strategy.job-total == 1 }}

CircleCI has no expression language, so the same flags are computed once in a Set test runner roles step and exported through BASH_ENV:

echo "export VORTEX_CI_IS_PHPUNIT_RUNNER=$([ "${CIRCLE_NODE_INDEX:-0}" -eq 0 ] && echo 1 || echo 0)"

Each step then reads only its own flag, and nothing else. On GitHub Actions that's the step condition:

- name: Test with PHPUnit
  if: ${{ env.VORTEX_CI_IS_PHPUNIT_RUNNER == 'true' }}

On CircleCI it's the first line of the step's command:

[ "${VORTEX_CI_IS_PHPUNIT_RUNNER:-1}" = "1" ] || exit 0

Adding a third container that runs Behat takes 3 edits. First raise the container count, which is parallelism: 3 on CircleCI or a third matrix instance on GitHub Actions. Then give the new container a profile in behat.yml, because Behat selects p<container index> and a profile that doesn't exist fails the run with profile 'p2' does not exist:

p2:
  gherkin:
    cache: '/tmp/behat_gherkin_cache'
    filters:
      tags: '@smoke,@p2&&~@skipped'

Then exclude the new tag from the p0 catch-all, which would otherwise run those scenarios a second time:

p0:
  gherkin:
    filters:
      tags: '@smoke,~@p1&&~@p2&&~@skipped'

Tag the scenarios you want on that container @p2, and they move across on the next run.

Two things are worth knowing before you start. A flag whose condition matches no container disables that tool everywhere while the job still passes green, so add the container before the flag that targets it. And a new container has to be the last one, never the first, because Behat derives its profile from the container index and container 0 is the catch-all. If you do put a container in front that doesn't run Behat, raise VORTEX_CI_BEHAT_PROFILE_OFFSET by 1 so the first Behat container still lands on p0.

Split security scanning into its own workflow

Gitleaks(Opens in a new tab/window), which scans a repository and its history for committed credentials, and composer audit(Opens in a new tab/window), which checks your installed packages against published security advisories, both used to run inside the lint job. So a red lint check told you something was wrong but not whether it was a style violation or a disclosed CVE, and both security checks paid for the full Docker application stack that the actual linters need in order to run.

Both now live in a standalone Security audit workflow on both providers, running a single audit job. It needs no application containers and no installed dependencies: Gitleaks runs straight from its image, and composer audit --locked audits what's pinned in composer.lock on the bare runner. A follow-up change reordered it so composer audit runs first and every check runs even after an earlier one fails.

BEFORE
  lint job  (builds the full Docker stack)
    ├─ PHPCS, PHPStan, Rector, ESLint, Stylelint ...
    ├─ Gitleaks         ◀─ security, waiting on a stack it
    └─ composer audit   ◀─ never uses; one red check for both

AFTER
  lint job  (builds the full Docker stack)
    └─ PHPCS, PHPStan, Rector, ESLint, Stylelint ...

  Security audit workflow  (no containers, no dependencies)
    ├─ composer audit --locked    runs first
    └─ Gitleaks                   runs even if the audit failed

Three things improve at once. A security finding stops taking your build down with it. The check itself got much cheaper, since it no longer builds a stack it never uses. And because failures no longer short-circuit, one finding stops hiding the ones behind it, so you fix a batch in a single pass rather than discovering them one push at a time.

It runs automatically on the same pushes, pull requests and tags as the main pipeline, and you can start it by hand. One thing to be deliberate about: a separate workflow means deploy can't declare a dependency on it, because neither provider supports cross-workflow dependencies. To make a security finding block merges, add the audit as a required status check in your branch protection rules.

The Security page(Opens in a new tab/window) covers secret scanning and dependency auditing in detail.

Reclaimed disk space on GitHub Actions runners

A GitHub Actions runner arrives with a large set of preinstalled toolchains: other language runtimes, SDKs, cached images. A Drupal build uses none of them, and on a project with a big database or a lot of files they are the difference between a build that fits on the runner and one that dies partway through with no useful error.

Setting the VORTEX_CI_FREE_DISK_SPACE repository variable to 1 strips those toolchains before the build starts.

It is off unless you set it, because clearing them costs time at the start of every run. Turn it on once a build has actually run out of disk, rather than pre-emptively.

Reduced disk pressure during provisioning

Provisioning accumulated intermediate files that then sat on the runner for the rest of the job, eating into the space the build itself needed.

That space is now freed as provisioning goes, so a large build has more room to work with.

It applies automatically in CI, with nothing to configure.

The Continuous integration page(Opens in a new tab/window) covers workflow structure, caching, triggers and parallelism.

Deployment

Added the deployment log to notifications

When a deployment notification told you something had gone wrong, finding out what meant going to the CI provider and digging through the run. The notification knew something had happened but couldn't tell you what.

The deployment log is now collected and attached to the outgoing notification, on every channel that supports it.

To enable it, set VORTEX_NOTIFY_LOG=1. That is the only variable you need, since the notify script picks a log directory for you; to put the logs somewhere specific, set VORTEX_NOTIFY_LOG_DIR.

Each notification channel has its own page under Notifications(Opens in a new tab/window).

Added stale deployment branch cleanup

Artifact deployments accumulated deployment branches indefinitely, because nothing ever removed them. On a long-running project that's years of dead branches nobody will ever look at again.

git-artifact(Opens in a new tab/window), which assembles the deployment artifact and pushes it to your hosting repository, moves to 1.7.0. That release can delete deployment branches matching a pattern once they pass a given age.

To enable it, set VORTEX_DEPLOY_ARTIFACT_CLEANUP_PATTERN to the branch pattern you want pruned. That cleans up matching branches older than 7 days; to change the age, set VORTEX_DEPLOY_ARTIFACT_CLEANUP_AGE.

The Artifact deployment page(Opens in a new tab/window) covers the full set of options.

Surfaced Lagoon deploy errors without debug mode

When a Lagoon deployment failed, the message you got said it had failed and little else. The actual error from the Lagoon CLI was only visible if you already had debug mode on, which nobody does on the run that fails.

The CLI error is printed on failure now, regardless of debug mode. With VORTEX_DEBUG=1 you additionally get per-task status and the full deploy output.

It applies automatically to Lagoon deployments. The practical effect is that the first failure tells you what went wrong, instead of the second one after you re-run it with debugging turned on.

Lagoon deployment is documented on the Lagoon page(Opens in a new tab/window).

Restored the Lagoon database override flag after use

A deployment that used the database override flag left it set afterwards. The next deployment then started from a state the previous one had changed, which is the kind of thing that works until it does not.

The flag is now restored to whatever state it was found in.

It applies automatically to Lagoon deployments.

Installation and updates

Refreshed the Drupal scaffold files for 11.4

Drupal's scaffold files, the ones core writes into your project such as .htaccess and robots.txt, drift from core over time. Nothing tells you when yours have fallen behind, because they live in your repository and look like ordinary project files.

They are refreshed to Drupal 11.4, and there are now tests that fail when they drift from what core ships.

The refreshed files arrive on update. If you have edited a scaffold file deliberately, review that hunk rather than taking it wholesale, since the update carries core's version.

Made the installer remove files the template stopped shipping

One change to the update mechanism itself is worth knowing about, because it changes what a Vortex update does to your codebase.

The installer used to lay the template over your project and only ever add or overwrite, never remove. So anything the template stopped shipping stayed with you forever: the references to a file would disappear, while the file itself sat there indefinitely. This bit hardest with deselected tools, because the leftover file is what the installer's tool discovery uses to detect that tool, so removing a tool would silently revert on the next update. It showed up as 3 different sites each removing Jest by hand, 3 different ways.

The installer now uses file checksums to tell your work apart from the template's. When the template stops shipping a file, it compares your copy against what was originally installed: if the file is untouched, it goes, and if you have edited it at all, it stays exactly as you left it. When the installer can't be certain either way, it keeps the file.

BEFORE
  download template ─▶ strip deselected ─▶ copy over the project
                                           └─ nothing is ever removed
  whatever the template stops shipping stays in the project
  └─ tool discovery still finds it, so a removed tool returns

AFTER
  download template ─▶ strip deselected ─▶ copy over the project
                                           └─ then, for each file the
                                              template no longer ships
                                              ├─ untouched   ─▶ removed
                                              ├─ you edited  ─▶ kept
                                              └─ can't tell  ─▶ kept

One mechanism covers every version of the problem: a tool you deselected, a CI provider you switched away from and left .circleci/ behind, a service you dropped, and files the template retires in some future release.

There's nothing to do, and nothing to opt into. Files you've modified are never touched.

The Updating Vortex page(Opens in a new tab/window) covers what an update does to your codebase.

Made Twig CS Fixer, dclint and hadolint selectable

Some tools shipped whether or not a project wanted them. Twig CS Fixer, the Docker Compose linter dclint and the Dockerfile linter hadolint all arrived with every install, and a project that did not want one removed it by hand, which the installer then partly undid on the next update.

All 3 are individually selectable in the installer's Tools multiselect now.

This affects new installs. Existing projects keep whatever they already have, and with the removal pass described below, deselecting a tool on a later update now actually removes it.

Documentation

Restructured the documentation around one page per topic

The same topic lived in several places. Behat was documented in 3 places, Diffy in 2, CI parallelism in 3, split across Tools, Drupal and Development sections that each held a different slice of the same subject. Finding the authoritative page for anything meant reading all of them.

Tools and Drupal are dissolved into a single Development section now, where every task and tool has exactly one canonical page, with deep topics organised as subsections: Environment, Modules, Security, Testing, Code quality, and per-channel Deployment notifications. 39 redirects map every legacy URL to its new home. Both documentation majors are also served from one combined site now, built on every branch rather than only on main.

BEFORE                          AFTER
  Development                     Development
    behat, phpunit, jest            Environment
    database, debugging               docker, pygmy, ahoy ...
    renovate ...                    Modules
  Drupal                              contributed, testmode ...
    modules, settings               Testing
    provision, migrations             behat, jest, phpunit
  Tools                             Code quality
    ahoy, docker, doctor              one page per linter
    behat, phpunit, jest            Security
    phpcs, phpstan, rector            scanning, audit
                                  Deployment
                                    Notifications
                                      one page per channel

  Behat in 3 places, Diffy in 2   one topic, one page
  CI parallelism in 3             39 redirects off the old URLs

Alongside the restructure, there's a new Modules reference page covering every shipped contributed module, and the generated variables table now covers variables read by the shipped tooling scripts, not just those in .env.

Browse vortextemplate.com/docs(Opens in a new tab/window) and use the version selector to switch majors. Your existing bookmarks redirect.

Upgrading

Run the update. The installer applies the template changes for you, and as of this release it also removes template-owned files that are no longer shipped, as long as you haven't modified them.

What the installer can't reach is your database, your CI provider's settings, your lock files, or code you wrote yourself. That leaves 9 things to do, and the first 2 matter most because each of them breaks something if you skip it.

  1. Uninstall admin_toolbar before you deploy. This is database state rather than files, and drush deploy aborts with The module admin_toolbar does not exist if the module is still enabled when the package disappears. Do it first.
  2. Regenerate the front-end lock files. Delete yarn.lock from the repository root and from your custom theme, run npm install in both, and commit the resulting package-lock.json.
  3. Point your own scripts at npm. Any custom script or CI step of yours that calls yarn needs to call npm. Remember the -- separator when forwarding arguments to npm test.
  4. Move custom ESLint rules to the flat config format. Anything you added to the old .eslintrc.json needs porting to eslint.config.mjs.
  5. Update a custom rector.php. If yours names a versioned DrupalSetList constant, change it, since the rule set is now derived from your installed core version.
  6. Rename VR_DIFFY_AUTO_BRANCHES to VR_DIFFY_PR_SKIP_BRANCHES. Only if you set it. It lives in your CI provider's settings, so nothing renames it for you.
  7. Translate your own un-ignore entries into deny form, then run git status. Files that were silently ignored before may now show up as untracked.
  8. Update anything that parses provision logs. The completed-task marker changed from < to +.
  9. Check your Behat scenarios against the behat-steps changes. Renamed entity-cleanup and watchdog skip tags, and the removal of redirectAssertModuleEnabled(), will fail scenarios that use them.

Runtime for this release: PHP 8.4.23, Drupal core ~11.4.5, Lagoon containers 26.8.1, and drevops/ci-runner26.8.0.

Full release notes, including every fix and dependency bump, are on the 1.41.0 release page(Opens in a new tab/window). If you're new to Vortex, vortextemplate.com(Opens in a new tab/window) is the place to start.