On Wednesday 21 October, PHP and Node.js published security releases on the same day.
PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 fixed two critical vulnerabilities, rated CVSS 9.2, that could allow unauthenticated remote code execution. The announcement didn't leave much room: there are no complete mitigations, updating is the only safe choice. Node.js 22.23.4, 24.22.1 and 26.12.1 fixed ten, four of them rated high, including an out-of-bounds write in the Brotli decoder that permits remote code execution.
Nothing I maintain was broken. Everything was working on the 20th and kept working on the 22nd. It just wasn't as safe anymore.
What I maintain is small, but it's spread across both runtimes: this website (a Vue frontend, prerendered at build time), the Laravel API behind it (the blog and the contact form), and Convertim, a desktop image converter built on Electron, with a CLI on top of the same engine. My host had already rolled out the PHP patch by the time I looked. The rest was on me.
This is how that weekend went, and the part that took the most time wasn't the updating.
Inventory before touching anything
A runtime patch is the trigger, not the whole job. The first thing I did was ask each project what it was carrying:
composer audit # the API npm audit # the website The API reported 28 advisories across 6 packages. The website, 27, two of them critical.
Those numbers aren't a measure of how bad the week was. An audit doesn't tell you what was published on Wednesday; it tells you everything that has piled up since the last time anyone asked. The runtime patch is what made me ask, and that's most of its value: it puts a date on a job that otherwise never has one.
Convertim was in a different position. Version 2.1 was finished and waiting for launch day, five days later. It runs on three things that each had fixes out: Electron, Node.js (the CLI is a self-contained binary built on Node 22) and libvips, the image library underneath sharp. And it had to ship with all three patched, because shipping a new version with old holes is worse than shipping nothing.
Fix inside the major
The obvious move is composer update and see what happens. What happens is that Composer resolves several advisories by moving packages to a new major version, because the packages that depend on them allow it.
That fixes the advisory. It also changes behaviour, in code I didn't write, in the week I least want surprises. A security patch should change as little as possible besides the hole. So I held the majors back and asked Composer for the fix within the versions I already had:
# Temporary constraints: they apply to this run only # and are never written to composer.json. composer update --with "some/package:^2.12" --with "other/package:^1" composer audit --with is the right tool for this because it doesn't stick. The next plain composer update will offer the new majors again, and that's on purpose: the decision to upgrade comes back, and next time it gets made deliberately, with time set aside for it, not as a side effect of a security fix.
Every advisory had a fixed release inside the current major. The audit came back clean.
On the way, I removed a dependency nothing in the code used anymore. Every package you don't need is one more to audit next time.
When there's no fixed version
The website was mostly an npm audit fix, with one exception.
One advisory was in postcss-selector-parser, which reaches the project through Tailwind CSS 3. There's no patched Tailwind 3 release that pulls in the fixed version. The official answer is to migrate to Tailwind 4, which is a real migration, not a patch.
npm has a way to force a sub-dependency to a version its parent didn't ask for:
"overrides": { "postcss-selector-parser": "^7.1.6" } This is the kind of fix that's easy to apply and hard to trust. Tailwind never tested itself against that version, and if the parser behaves differently, the CSS it generates changes. Nothing errors. The site just looks slightly different somewhere, on some page nobody opens for a month.
So the override had one condition to stay: the generated CSS had to be identical to the one built before it, byte for byte. It was. The override is in, with a note to remove it when the site moves to Tailwind 4.
Some of the others were in development tools: the test runner and the headless browser driver I use for screenshots and social images. npm could only fix those by moving them to a new major. Here I made the exception to my own rule, because neither of them ships: they never reach anyone visiting the site. I moved them to the new majors and reran everything they're used for. The tests passed, and the screenshot scripts still run.
27 to zero.
Proving nothing changed
Getting the audit to zero took an afternoon. Showing that the update hadn't changed anything for the people using the site took longer, and it's the part I'd argue for.
"Tests pass" wasn't enough on its own, because the things most likely to break here are the things tests rarely cover: generated CSS, HTML that's rendered once at build time, chunks that a bundler decides how to split. So I compared outputs instead of trusting them.
The frontend. I kept a production build from before the update and made a new one after. Then I diffed them:
- CSS and images: identical.
- HTML: identical except for the
modulepreloadhints, because the new Vite version splits the JavaScript into chunks differently. Same code, different packaging.
Then the new build ran in a real browser against a local copy of the API: the pages, the language switch and the contact form, which is the one thing on the site where a silent failure costs you a message you'll never know you missed.
The API. The API renders the blog's Markdown into HTML, and the Markdown library was one of the packages that changed. So I rendered every post before and after the update and diffed the HTML. Identical. Same with the list of routes and the scheduled tasks: no route appeared or disappeared, no job moved.
All of that ran against a throwaway database. Never the one I develop with, and certainly never production.
Only then did both go up, on Sunday.
Convertim, days before launch
Convertim deserved the most care, for a reason that's easy to miss: an image converter opens files from anywhere. Downloads, phone transfers, email attachments, folders of things nobody has looked at in years. The code that parses those files is libvips. In a tool like this, an image library fix isn't background maintenance. It's the front door.
So the 2.1 that had been ready for weeks got rebuilt on top of current versions: Electron 45, sharp 0.35.5 with libvips 8.18.7, and Node 22.23.4 for the CLI binary. npm audit at zero.
Then the full test suite again, all 490 tests, on the rebuilt app. Convertim's tests check exact results (dimensions, pixels, file names, JSON output), not just that nothing throws, which matters when the thing that changed is the library doing the pixels. They passed. 2.1 shipped on the 26th with every fix in.
The awkward part of patch week is that it lands when it lands. A release that's "done" isn't done if what's underneath it changed after you finished.
What I left out on purpose
Zero advisories doesn't mean everything is on the latest version, and it shouldn't:
- The majors I held back in the API. They're still there, waiting. They'll come back the next time I run a plain update, and they'll get their own week.
- Tailwind 4. A migration with visual risk across every page. When it happens, the override goes with it.
- Everything that wasn't affected. A patch week is not the moment to tidy up. Every extra change makes the diff harder to read, and the diff is how you know the update didn't break anything.
A security patch is a fix. A major upgrade is a project. Mixing them is how a Wednesday advisory turns into a broken page on Friday.
Routine, not emergency
None of this was clever. It was an inventory, a few constraints, one override with a condition, and a lot of diffing. The only reason it fit into a weekend is that nothing was far behind: every advisory had a fix inside the version I already had, except one, and that one had a workaround I could verify.
That's the case for keeping dependencies current when nothing is on fire. Not that updates are pleasant, but that they're small when they're frequent. An up-to-date dependency is one you can patch in an afternoon. One that's three majors behind turns every advisory into a migration, and every migration into a reason to wait.
In two weeks: the small update server behind my desktop apps, and why an app I once described as never touching the network now checks for updates every time it opens.
