<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marco Orta</title>
    <description>The latest articles on DEV Community by Marco Orta (@marco_odev).</description>
    <link>https://dev.to/marco_odev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4135235%2F70d2d5a4-642c-4df8-bd37-8dcaef1e7d17.png</url>
      <title>DEV Community: Marco Orta</title>
      <link>https://dev.to/marco_odev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marco_odev"/>
    <language>en</language>
    <item>
      <title>Ollama on an AMD GPU: Five Failures That Drop You to CPU Without Telling You</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:22:27 +0000</pubDate>
      <link>https://dev.to/marco_odev/ollama-on-an-amd-gpu-five-failures-that-drop-you-to-cpu-without-telling-you-20be</link>
      <guid>https://dev.to/marco_odev/ollama-on-an-amd-gpu-five-failures-that-drop-you-to-cpu-without-telling-you-20be</guid>
      <description>&lt;p&gt;&lt;strong&gt;The worst Ollama failure on an AMD GPU isn't the one that takes your server down. It's the one that takes nothing down: Ollama starts, responds, answers correctly — and does it on CPU, at 15.89 tokens per second instead of 132.48.&lt;/strong&gt; An 8.3x penalty that shows up in no log unless you go looking, because as far as Ollama is concerned that isn't an error. It's a fallback.&lt;/p&gt;

&lt;p&gt;This article is the summary of three weeks measuring a &lt;strong&gt;24 GB Radeon RX 7900 XTX&lt;/strong&gt; on a Ryzen 9 7950X3D, across Windows, native Linux and inside WSL2. It isn't an install guide — there are plenty of those — but an inventory of &lt;strong&gt;the five ways this setup breaks without saying so&lt;/strong&gt;, with the numbers for each and with what I proved &lt;em&gt;doesn't&lt;/em&gt; fix them, which is usually the more useful half.&lt;/p&gt;

&lt;p&gt;The tok/s here belong to one specific card in one specific environment. What does transfer verbatim are the failure modes and the method for catching them.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: the thirty-second check
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this article, take this. Ollama &lt;strong&gt;probes the GPU exactly once, at startup, and never looks again&lt;/strong&gt;. So there are two questions to answer every time, and neither is answered by noticing that the chat "feels fast":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Which backend did the server declare at startup?&lt;/strong&gt; It's in the startup log (&lt;code&gt;%LOCALAPPDATA%\Ollama\server.log&lt;/code&gt; on Windows, &lt;code&gt;journalctl -u ollama&lt;/code&gt; on Linux):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;library=Vulkan compute=0.0 name=Vulkan0 description="AMD Radeon RX 7900 XTX"
libdirs=ollama,vulkan  total="24.0 GiB" available="23.2 GiB"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you're looking for is &lt;code&gt;library=&lt;/code&gt;. If it says &lt;code&gt;cpu&lt;/code&gt;, that's it: there is no GPU, and nothing else is going to tell you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Did the model load in full?&lt;/strong&gt; &lt;code&gt;ollama ps&lt;/code&gt; gives you the split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama ps
&lt;span class="c"&gt;# NAME              SIZE     PROCESSOR&lt;/span&gt;
&lt;span class="c"&gt;# gpt-oss:20b       16 GB    100% GPU&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that isn't &lt;strong&gt;100% GPU&lt;/strong&gt; is a model split between GPU and CPU, and that doesn't raise an error either.&lt;/p&gt;

&lt;p&gt;One detail that cost me a whole verifier script: &lt;strong&gt;don't require &lt;code&gt;compute=gfx1100&lt;/code&gt; or a &lt;code&gt;pci_id&lt;/code&gt;&lt;/strong&gt;. The Vulkan backend reports &lt;code&gt;compute=0.0&lt;/code&gt; and &lt;code&gt;pci_id=""&lt;/code&gt; where ROCm gave real values, so a script checking those fields will fail a perfectly healthy system. Read &lt;code&gt;library=&lt;/code&gt; and &lt;code&gt;libdirs=&lt;/code&gt;, nothing else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure 1: the CPU's integrated GPU steals the job
&lt;/h2&gt;

&lt;p&gt;This one is treacherous because the guilty hardware isn't the hardware you're looking at.&lt;/p&gt;

&lt;p&gt;If you have a desktop Ryzen with integrated graphics — nearly all of the 7000 series and later do — your machine has &lt;strong&gt;two AMD GPUs&lt;/strong&gt;, not one. And ROCm's enumeration orders them however it likes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ROCm0: AMD Radeon(TM) Graphics (36694 MiB)   &amp;lt;- the integrated one
ROCm1: AMD Radeon RX 7900 XTX (24560 MiB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The integrated one comes first and reports 36 GB of VRAM (which it doesn't have: that's system RAM). &lt;code&gt;llama.cpp&lt;/code&gt; binaries pick it, load kernels compiled for &lt;code&gt;gfx1100&lt;/code&gt;, and blow up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ggml-cuda.cu:106: ROCm error
ROCm error: device kernel image is invalid
  current device: 1, in function ggml_cuda_kernel_launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What doesn't fix it&lt;/strong&gt; — and I tried everything before giving up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--device ROCm1&lt;/code&gt; (pick it by name)&lt;/td&gt;
&lt;td&gt;⛔ same error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;HIP_VISIBLE_DEVICES=0&lt;/code&gt; and &lt;code&gt;=1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;⛔ same error, or "no usable GPU found"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ROCR_VISIBLE_DEVICES=1&lt;/code&gt; + &lt;code&gt;--device ROCm0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;⛔ same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ROCBLAS_TENSILE_LIBPATH&lt;/code&gt; to the kernel directory&lt;/td&gt;
&lt;td&gt;⛔ same (the &lt;code&gt;gfx1100&lt;/code&gt; kernels &lt;strong&gt;were there&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What does fix it: disabling the integrated GPU in the BIOS.&lt;/strong&gt; With a single device there's nothing to pick wrong and it starts first try, with no flags:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--list-devices&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integrated + dedicated&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;only the RX 7900 XTX&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;standalone &lt;code&gt;llama-server&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;⛔ &lt;code&gt;kernel image is invalid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;136 tok/s&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;118 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;132.5 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reasonable fear was losing VRAM by moving the monitor onto the dedicated card. &lt;strong&gt;Measured: it costs 0.2 GiB.&lt;/strong&gt; With the display running 3440×1440 at 165 Hz off the big card, Ollama still reports &lt;code&gt;total 24.0 GiB / available 23.8 GiB&lt;/code&gt;. The trade is free.&lt;/p&gt;

&lt;p&gt;And a note so you don't lose your mind: &lt;strong&gt;under Ollama this doesn't happen&lt;/strong&gt;, because Ollama masks the integrated GPU on its own and to it the dedicated card is &lt;code&gt;ROCm0&lt;/code&gt;. That's why you can have Ollama working and &lt;code&gt;llama-server&lt;/code&gt; failing on the same machine with the same weights. You're not imagining it: they're two different enumerations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure 2: updating anything resets the backend
&lt;/h2&gt;

&lt;p&gt;I said it above, but it earns its own section because it's the most expensive one: &lt;strong&gt;Ollama probes the GPU at startup and that's it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;After an AMD driver reinstall, the service just carried on like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inference compute  id=cpu  library=cpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error. No warning. &lt;strong&gt;15.89 tok/s against 132.48.&lt;/strong&gt; Restarting Ollama put it back on &lt;code&gt;library=ROCm compute=gfx1100&lt;/code&gt; and back to 132.&lt;/p&gt;

&lt;p&gt;There's a second version of the same failure, and it's worse because you don't trigger it: &lt;strong&gt;every Ollama update reinstalls the ROCm bundle&lt;/strong&gt;. If you'd deliberately switched to Vulkan, the update quietly puts you back on ROCm. Nobody tells you; the number you'd been measuring for weeks simply stops being the same number.&lt;/p&gt;

&lt;p&gt;The operating rule that came out of this: &lt;strong&gt;run the check after touching drivers, BIOS or Ollama version.&lt;/strong&gt; Not after something goes wrong — after touching anything, always, even when nothing seems wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure 3: inside WSL2 there is no GPU for Ollama, and there won't be
&lt;/h2&gt;

&lt;p&gt;This is the one that ate the most time, so here's the detail so it doesn't eat yours.&lt;/p&gt;

&lt;p&gt;The question was reasonable: if native Linux beats Windows by 6.7 to 19.5% in tok/s, can I take it inside WSL and skip the reboot? &lt;strong&gt;The answer is that Ollama in WSL doesn't use the GPU at all.&lt;/strong&gt; And it isn't a driver problem.&lt;/p&gt;

&lt;p&gt;What I measured, in order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/dev/kfd&lt;/code&gt; and &lt;code&gt;/sys/class/kfd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;⛔ &lt;strong&gt;don't exist&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/dev/dxg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ exists (the WSL path)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does HIP compute in WSL?&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;YES&lt;/strong&gt; — probe built with &lt;code&gt;hipcc&lt;/code&gt;: &lt;code&gt;hipGetDeviceCount rc=0 n=1&lt;/code&gt;, &lt;code&gt;gfx1100&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama detects GPU&lt;/td&gt;
&lt;td&gt;⛔ &lt;code&gt;library=cpu&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HSA_ENABLE_DXG_DETECTION=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⛔ changes nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;llama-server --list-devices&lt;/code&gt; with ROCm&lt;/td&gt;
&lt;td&gt;⛔ &lt;code&gt;Available devices: (none)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;llama-server --list-devices&lt;/code&gt; with Vulkan&lt;/td&gt;
&lt;td&gt;⛔ &lt;code&gt;Available devices: (none)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The paradox that explains it:&lt;/strong&gt; HIP &lt;em&gt;does&lt;/em&gt; work in WSL, through the HSA runtime that talks to &lt;code&gt;/dev/dxg&lt;/code&gt;. But &lt;strong&gt;Ollama enumerates GPUs by reading the KFD topology from &lt;code&gt;sysfs&lt;/code&gt;&lt;/strong&gt;, which WSL doesn't expose. The driver isn't missing — Ollama is looking through a door WSL doesn't have.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;OLLAMA_DEBUG=1&lt;/code&gt; the real cause surfaces, otherwise buried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failure during llama-server GPU discovery
error="llama-server --list-devices failed: signal: segmentation fault (core dumped)"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a recipe going around — &lt;a href="https://github.com/ollama/ollama/issues/16551" rel="noopener noreferrer"&gt;Ollama issue 16551&lt;/a&gt;, which is the identical case: same card, same WSL2, same ROCm 7.2 — whose author reports &lt;code&gt;library=ROCm compute=gfx1100&lt;/code&gt; at 100% GPU by dropping in the ROCm bundle and setting &lt;code&gt;HSA_ENABLE_DXG_DETECTION=1&lt;/code&gt;. &lt;strong&gt;Tried here, does not reproduce.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What about the Vulkan backend? In WSL it would need the &lt;strong&gt;&lt;code&gt;dzn&lt;/code&gt;&lt;/strong&gt; ICD (Vulkan over D3D12), which Ubuntu doesn't package. You could build Mesa with &lt;code&gt;-Dvulkan-drivers=microsoft-experimental&lt;/code&gt; and see what happens, but that's a lot of work for an uncertain payoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practical conclusion: for Ollama, WSL is out.&lt;/strong&gt; The two exits are Windows Ollama (which does use the GPU, and is what exists today) or booting native Linux. And if you develop inside WSL like I do, the good news is &lt;strong&gt;you don't need to move Ollama&lt;/strong&gt;: with WSL2 in &lt;code&gt;networkingMode=mirrored&lt;/code&gt;, &lt;code&gt;localhost:11434&lt;/code&gt; from WSL reaches Windows Ollama with zero configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure 4: the model doesn't fit, gets split, and keeps answering
&lt;/h2&gt;

&lt;p&gt;When you ask for more context than fits in VRAM, Ollama doesn't say no. &lt;strong&gt;It splits the model between GPU and CPU and carries on.&lt;/strong&gt; The symptom is &lt;code&gt;size_vram &amp;lt; size&lt;/code&gt; in &lt;code&gt;/api/ps&lt;/code&gt;, and nothing else.&lt;/p&gt;

&lt;p&gt;Measured with &lt;code&gt;qwen3.8&lt;/code&gt; (27.3B) on the 7900 XTX, with &lt;code&gt;OLLAMA_FLASH_ATTENTION=1&lt;/code&gt; and the KV cache at &lt;code&gt;q8_0&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;num_ctx&lt;/code&gt; requested&lt;/th&gt;
&lt;th&gt;On GPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;262,144&lt;/td&gt;
&lt;td&gt;⛔ &lt;strong&gt;63.4%&lt;/strong&gt; — a third on CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;131,072&lt;/td&gt;
&lt;td&gt;⛔ 92.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;122,880&lt;/td&gt;
&lt;td&gt;✅ 100% (with &lt;strong&gt;0.6 GiB&lt;/strong&gt; of headroom)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With 0.6 GiB free, anything you open on the desktop knocks it over. That ceiling belongs to that afternoon and that display, not to the model.&lt;/p&gt;

&lt;p&gt;And here's the warning that nearly cost me a bad decision: &lt;strong&gt;&lt;code&gt;ollama ps&lt;/code&gt; underestimates real VRAM by 4.3 to 6.8 GiB.&lt;/strong&gt; Cross-checked against the system counter, across four models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ollama ps&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Real VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-oss:20b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12.33 GiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.59 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;muse-glimmer&lt;/code&gt; (27.9B)&lt;/td&gt;
&lt;td&gt;15.49 GiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.77 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gemma4&lt;/code&gt; (25.8B)&lt;/td&gt;
&lt;td&gt;17.24 GiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22.37 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;qwen3.8&lt;/code&gt; (27.3B)&lt;/td&gt;
&lt;td&gt;16.61 GiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23.36 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For memory decisions, the &lt;code&gt;ps&lt;/code&gt; column is useless.&lt;/strong&gt; It's only good for telling you whether Ollama split to CPU. If you're working out whether a model fits, measure system VRAM, not what Ollama declares.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/llm-token-counter/" rel="noopener noreferrer"&gt;LLM Token Counter &amp;amp; AI Cost Calculator&lt;/a&gt; — Count the tokens in your text or prompt and estimate the cost across GPT-5, Claude, Gemini, Kimi K3, Grok and DeepSeek. Exact OpenAI counting, per-call and monthly costs, with prompt caching.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Failure 5: it trims your context and lets you believe you have it
&lt;/h2&gt;

&lt;p&gt;A subtler variant of the previous one. Ask &lt;code&gt;muse-glimmer&lt;/code&gt; or &lt;code&gt;gpt-oss&lt;/code&gt; for &lt;code&gt;num_ctx: 262144&lt;/code&gt; and &lt;strong&gt;they load perfectly happily&lt;/strong&gt; — and the &lt;code&gt;context_length&lt;/code&gt; returned by &lt;code&gt;/api/ps&lt;/code&gt; says &lt;code&gt;131072&lt;/code&gt;. What you asked for isn't what you got.&lt;/p&gt;

&lt;p&gt;The underlying cause is that these models have an architecture ceiling below the card's. And it explains something counterintuitive that shows up when you measure: &lt;strong&gt;on-disk size does not predict maximum context&lt;/strong&gt;. What decides is whether the model uses a &lt;strong&gt;sliding window&lt;/strong&gt; (SWA) or dense KV:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;SWA&lt;/th&gt;
&lt;th&gt;KV growth from 16K to 128K&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;muse-glimmer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2048&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-oss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;+0.36 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;+0.40 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen3.8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;— (dense KV)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.35 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of the four hit their architecture ceiling and never notice the VRAM. The only one that actually pays for context is the only one that overflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always read the real &lt;code&gt;context_length&lt;/code&gt; from &lt;code&gt;/api/ps&lt;/code&gt;, not the one you sent.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ROCm or Vulkan in 2026: what each one wins
&lt;/h2&gt;

&lt;p&gt;This is the year's underlying shift, and the reason this article lands now. Ollama added &lt;a href="https://www.phoronix.com/news/ollama-Experimental-Vulkan" rel="noopener noreferrer"&gt;experimental Vulkan support&lt;/a&gt; in 0.12.6 and it has been reaching the binaries through 2026, with the argument of opening the door to the AMD and Intel GPUs ROCm never supported — which is most consumer cards.&lt;/p&gt;

&lt;p&gt;On my machine, forcing each backend with the same Ollama and the same weights, &lt;strong&gt;Vulkan wins&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Vulkan's gain over ROCm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-oss:20b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+17.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+30.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;muse-glimmer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+30.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Disjoint ranges across two independent rounds, so the signal is real. And it lines up with what others report: on an RX 9070 XT, Llama 2 7B Q4_0 decodes at &lt;a href="https://runaihome.com/blog/rdna4-vulkan-vs-rocm-local-llm-benchmark-2026/" rel="noopener noreferrer"&gt;137 tok/s on Vulkan against 101 on ROCm&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With two caveats almost nobody adds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First: the delta compares two things at once. On Windows it's ROCm 7.1 against AMDVLK; on Linux it's ROCm 7.2.4 against RADV. A worse ROCm inflates exactly the same number as a better Vulkan, and they aren't separated.&lt;/p&gt;

&lt;p&gt;Second is a cost I didn't see coming. &lt;strong&gt;Vulkan leaves 0.6 GiB less VRAM available&lt;/strong&gt;: 23.2 GiB declared against 23.8 with ROCm. For three of my four models it makes no difference because they weren't near the limit. For &lt;code&gt;qwen3.8&lt;/code&gt;, which had exactly 0.6 GiB of headroom, it eats the lot:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;num_ctx&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;On Vulkan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;122,880 (the ROCm ceiling)&lt;/td&gt;
&lt;td&gt;⛔ &lt;strong&gt;93.8%&lt;/strong&gt; on GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;98,304&lt;/td&gt;
&lt;td&gt;✅ 100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The real trade with this lever isn't speed against stability: it's speed against context.&lt;/strong&gt; If your model is near the card's limit, Vulkan costs you window. If it isn't, it's free.&lt;/p&gt;

&lt;p&gt;What remains unmeasured — by me and by almost everyone — is &lt;strong&gt;Vulkan's stability over multi-hour sessions&lt;/strong&gt;. For a ten-minute chat it doesn't matter; for an agent looping all afternoon, it does.&lt;/p&gt;




&lt;h2&gt;
  
  
  The full checklist
&lt;/h2&gt;

&lt;p&gt;What I do now every time I touch this machine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Disable the integrated GPU in the BIOS&lt;/strong&gt; if you have a Ryzen with graphics. It costs 0.2 GiB and removes an entire axis of ambiguity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart Ollama after touching drivers, BIOS or version.&lt;/strong&gt; Not optional: it probes once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;library=&lt;/code&gt; in the startup log.&lt;/strong&gt; Not &lt;code&gt;compute=&lt;/code&gt;, not &lt;code&gt;pci_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that &lt;code&gt;ollama ps&lt;/code&gt; says 100% GPU&lt;/strong&gt; before trusting any measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the real &lt;code&gt;context_length&lt;/code&gt; from &lt;code&gt;/api/ps&lt;/code&gt;&lt;/strong&gt;, not the one you sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For memory decisions, measure system VRAM&lt;/strong&gt;, not the &lt;code&gt;ollama ps&lt;/code&gt; column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't try it in WSL.&lt;/strong&gt; Point at &lt;code&gt;localhost:11434&lt;/code&gt; against Windows Ollama.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close Chrome, Spotify and Slack before measuring.&lt;/strong&gt; Less for the tok/s than for the range: a real 2% difference disappears if noise widens it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the one that covers all of them: &lt;strong&gt;an empty result is not a zero.&lt;/strong&gt; When an instrument returns nothing, the reading isn't "there is none"; it's "I couldn't look." On this machine, &lt;code&gt;Get-Process | Modules&lt;/code&gt; returned &lt;code&gt;0&lt;/code&gt; modules and I nearly read it as "no layers loaded." It was 0 because the process was running elevated and I couldn't see inside. What saved it was having a control case next to it — &lt;code&gt;explorer&lt;/code&gt;, same user, 389 modules. Without that contrast, the zero reads as data.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you're weighing up the hardware
&lt;/h2&gt;

&lt;p&gt;The honest conclusion after all of this: &lt;strong&gt;a consumer Radeon runs local models perfectly well in 2026&lt;/strong&gt;, and the numbers sit alongside what gets published for NVIDIA in the same bracket. ROCm 7.2 is the first release where Ollama, LM Studio, llama.cpp and vLLM all work on Radeon without hand-patching.&lt;/p&gt;

&lt;p&gt;What still costs more than on NVIDIA isn't the tok/s: &lt;strong&gt;it's the diagnosis&lt;/strong&gt;. Everything in this article is time you wouldn't have spent on a 4090 — not because the hardware is worse, but because the path is less trodden and the failures are silent instead of loud.&lt;/p&gt;

&lt;p&gt;If that trade works for you — and it works for me, because 24 GB of VRAM at this price doesn't exist on the other side — this article is the shortcut past the five holes.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/local-llm-business-privacy/" rel="noopener noreferrer"&gt;AI Without Sending Data Out: Local LLMs for Law Firms and Small Businesses&lt;/a&gt; — the why behind all of this: what a local model actually solves, and what it doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/how-much-openai-claude-gemini-api-costs-2026/" rel="noopener noreferrer"&gt;How Much Do the OpenAI, Claude and Gemini APIs Cost in 2026&lt;/a&gt; — the number to compare against before buying a card.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/ai-model-retirements-2026/" rel="noopener noreferrer"&gt;The AI model your business runs on is being retired&lt;/a&gt; — why keeping the model in-house stops being a luxury.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I know whether Ollama is using my AMD GPU?
&lt;/h3&gt;

&lt;p&gt;With two checks, not one. First, the server startup log (%LOCALAPPDATA%\Ollama\server.log on Windows, journalctl -u ollama on Linux) must declare library=ROCm or library=Vulkan; if it says library=cpu, there is no GPU. Second, ollama ps must say 100% GPU: any other value means the model is split between GPU and CPU. Ollama raises no error in either case, it just runs slower.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Ollama run on CPU after a driver update?
&lt;/h3&gt;

&lt;p&gt;Because Ollama probes the GPU only once, at startup, and never looks again. If you update drivers, change the BIOS or install a new version while the service is running, it keeps the stale detection and falls back to CPU with no error at all. Measured on an RX 7900 XTX: 15.89 tok/s on CPU against 132.48 on GPU, an 8.3x penalty. The fix is to restart Ollama and re-check the library= line in the log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use an AMD GPU with Ollama inside WSL2?
&lt;/h3&gt;

&lt;p&gt;No. Verified in August 2026 with an RX 7900 XTX and ROCm 7.2: Ollama enumerates GPUs by reading the KFD topology from sysfs, and WSL exposes neither /dev/kfd nor /sys/class/kfd. HIP does compute in WSL through /dev/dxg, but Ollama does not look there, and HSA_ENABLE_DXG_DETECTION=1 does not change it. Both ROCm and Vulkan return "Available devices: (none)". The practical exit is to point at localhost:11434 against Windows Ollama: with WSL2 in networkingMode=mirrored it works with no configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is ROCm or Vulkan better for Ollama on an AMD GPU?
&lt;/h3&gt;

&lt;p&gt;It depends on whether your model is near the VRAM limit. On an RX 7900 XTX, Vulkan wins between 17.8% and 30.3% in tokens per second depending on the model, with disjoint ranges across two rounds. But Vulkan leaves 0.6 GiB less VRAM available (23.2 against 23.8 GiB), so a model that was tight on context stops fitting. The trade is not speed against stability: it is speed against context window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does llama.cpp fail with "device kernel image is invalid" on a Ryzen?
&lt;/h3&gt;

&lt;p&gt;Because the machine has two AMD GPUs: the dedicated card and the CPU's integrated graphics. ROCm enumerates the integrated one first, the binary picks it, and kernels compiled for gfx1100 do not work on that chip. Neither --device by name nor HIP_VISIBLE_DEVICES nor ROCR_VISIBLE_DEVICES fixes it. The only thing that works is disabling the integrated GPU in the BIOS, and it costs just 0.2 GiB of VRAM. Ollama does not suffer from this because it masks the integrated GPU on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much VRAM does a model in Ollama actually need?
&lt;/h3&gt;

&lt;p&gt;More than ollama ps says, which underestimates by 4.3 to 6.8 GiB. Measured across four models on a 24 GB card: gpt-oss:20b declares 12.33 GiB and occupies 16.59; qwen3.8 declares 16.61 and occupies 23.36. To decide whether a model fits you have to read the system VRAM counter; the ollama ps column is only good for telling you whether the model was split to CPU.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/ollama-amd-gpu-silent-cpu-fallback/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ollama</category>
      <category>llm</category>
      <category>linux</category>
    </item>
    <item>
      <title>GitHub Actions Node 20 Deprecation: What Broke on 23 September (and Why node20 Isn't the Problem)</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:22:23 +0000</pubDate>
      <link>https://dev.to/marco_odev/github-actions-node-20-deprecation-what-broke-on-23-september-and-why-node20-isnt-the-problem-4lcc</link>
      <guid>https://dev.to/marco_odev/github-actions-node-20-deprecation-what-broke-on-23-september-and-why-node20-isnt-the-problem-4lcc</guid>
      <description>&lt;p&gt;&lt;strong&gt;GitHub removed Node 20 from the Actions runners on 23 September 2026, and an action that declares &lt;code&gt;using: node20&lt;/code&gt; still doesn't fail for that: since 16 June the runner rewrites &lt;code&gt;node20&lt;/code&gt; to &lt;code&gt;node24&lt;/code&gt; before it executes anything, and it does the same with &lt;code&gt;node12&lt;/code&gt; and &lt;code&gt;node16&lt;/code&gt;. What went away on the 23rd was the escape hatch: the &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&lt;/code&gt; variable no longer brings Node 20 back. The next date with real consequences is 2 November, when &lt;code&gt;macos-14&lt;/code&gt; is retired.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That inverts who is at risk. If your CI has been green since June without that variable, the 23rd changed nothing for you on GitHub-hosted runners. The ones that break are precisely the teams that &lt;em&gt;already hit&lt;/em&gt; an action that can't handle Node 24, set the variable to keep moving, and filed the real fix under later. And around that date there are four more with real consequences: the self-hosted runner minimum version (25 September), retention for checks and runs (1 October), the &lt;code&gt;macos-14&lt;/code&gt; brownouts (October) and its retirement (2 November).&lt;/p&gt;

&lt;p&gt;I checked this against the runner's source code and against this site's own CI, which uses two &lt;code&gt;node20&lt;/code&gt; actions. Everything below comes with its source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The calendar, by date
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;th&gt;How to detect it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;14, 16 and 18 Sep&lt;/strong&gt; (11:00-15:00 ET)&lt;/td&gt;
&lt;td&gt;Brownouts ahead of the self-hosted runner minimum version&lt;/td&gt;
&lt;td&gt;Outdated runners &lt;strong&gt;cannot register or run jobs&lt;/strong&gt; during the window&lt;/td&gt;
&lt;td&gt;Runner deprecations API (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;17 Sep&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ubuntu-22.04&lt;/code&gt; and &lt;code&gt;ubuntu-22.04-arm&lt;/code&gt; enter deprecation&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Nothing fails yet&lt;/strong&gt;; queues may be longer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;grep&lt;/code&gt; for the label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;21-30 Sep&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;windows-11-arm&lt;/code&gt; moves to the Visual Studio 2026 image&lt;/td&gt;
&lt;td&gt;The toolchain underneath changes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep windows-11-arm&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;23 Sep&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Node 20 leaves the runners&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&lt;/code&gt; is ignored; Linux ARM32 runners lose support&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;grep&lt;/code&gt; for the variable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;25 Sep&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-hosted runner minimum version is enforced&lt;/td&gt;
&lt;td&gt;Registering requires ≥ 2.329.0; a runner that doesn't install each release within 30 days &lt;strong&gt;stops receiving jobs&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Deprecations API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1 Oct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Actions retention now covers checks, workflow runs and statuses&lt;/td&gt;
&lt;td&gt;Anything past your retention period (90 days by default) &lt;strong&gt;is deleted&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Repo/org retention setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;5-31 Oct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Eight &lt;code&gt;macos-14&lt;/code&gt; brownouts&lt;/td&gt;
&lt;td&gt;Jobs scheduled inside the window &lt;strong&gt;fail&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep macos-14&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 Nov&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;macos-14&lt;/code&gt; retired&lt;/td&gt;
&lt;td&gt;Jobs with that label &lt;strong&gt;terminate with an error&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep macos-14&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17 Apr 2027&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ubuntu-22.04&lt;/code&gt; retired (brownouts before, in March and April)&lt;/td&gt;
&lt;td&gt;Jobs terminate with an error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;grep&lt;/code&gt; for the label&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Node 20: what actually happens on 23 September
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the notice says
&lt;/h3&gt;

&lt;p&gt;GitHub's &lt;a href="https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/" rel="noopener noreferrer"&gt;changelog post from 19 September 2025&lt;/a&gt;, with an editor's note dated 25 August 2026, sets two dates: from &lt;strong&gt;16 June 2026&lt;/strong&gt; runners use Node 24 by default, and the &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true&lt;/code&gt; opt-out back to Node 20 "will only work until we upgrade the runner and remove Node20 on &lt;strong&gt;September 23rd, 2026&lt;/strong&gt;." It adds two platform limits: Node 24 is incompatible with macOS 13.4 and lower, and it has no official ARM32 support.&lt;/p&gt;

&lt;p&gt;What the notice does not explain is what happens to an action whose &lt;code&gt;action.yml&lt;/code&gt; still says &lt;code&gt;using: node20&lt;/code&gt;. That lives in the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the runner code says
&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/HandlerFactory.cs" rel="noopener noreferrer"&gt;&lt;code&gt;HandlerFactory.cs&lt;/code&gt;&lt;/a&gt; (identical at tag v2.337.0, the current release), the runner does two things before launching a JavaScript action:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the action declares &lt;code&gt;node12&lt;/code&gt; or &lt;code&gt;node16&lt;/code&gt;, it bumps it to &lt;code&gt;node20&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it ends up on &lt;code&gt;node20&lt;/code&gt;, it asks &lt;a href="https://github.com/actions/runner/blob/main/src/Runner.Common/Util/NodeUtil.cs" rel="noopener noreferrer"&gt;&lt;code&gt;NodeUtil.DetermineActionsNodeVersion&lt;/code&gt;&lt;/a&gt; which version to use.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That function has three phases, driven by feature flags on GitHub's side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Phase 3: Always use Node 24 regardless of environment variables&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requireNode24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NodeMigration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Node24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="c1"&gt;// Phase 2: Node 24 is the default&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;useNode24ByDefault&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowUnsecureNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NodeMigration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Node20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NodeMigration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Node24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/actions/runner/pull/3948" rel="noopener noreferrer"&gt;PR #3948&lt;/a&gt;, which introduced this, is blunt about it: in phase 3, "All actions use Node 24" and "No opt-out options available." No branch returns an error for declaring &lt;code&gt;node20&lt;/code&gt;. The action runs on a different Node, which is very different from not running.&lt;/p&gt;

&lt;p&gt;Note the order too: &lt;code&gt;node12&lt;/code&gt; → &lt;code&gt;node20&lt;/code&gt; → &lt;code&gt;node24&lt;/code&gt;. An old action with &lt;code&gt;using: node16&lt;/code&gt; (for example &lt;code&gt;actions/checkout@v3&lt;/code&gt;) ends up on Node 24 as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  The proof: this site's CI
&lt;/h3&gt;

&lt;p&gt;This blog's CI workflow uses &lt;code&gt;actions/checkout@v4&lt;/code&gt; and &lt;code&gt;actions/setup-node@v4&lt;/code&gt;. The official &lt;code&gt;action.yml&lt;/code&gt; files at those tags declare &lt;code&gt;using: node20&lt;/code&gt;; from &lt;code&gt;v5&lt;/code&gt; onward they declare &lt;code&gt;node24&lt;/code&gt; (I checked tags &lt;code&gt;v4&lt;/code&gt; through &lt;code&gt;v7&lt;/code&gt; of both). The run on 11 September, on runner 2.337.0 with the &lt;code&gt;ubuntu-24.04&lt;/code&gt; image, finished green with this annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node.js 20 is deprecated. The following actions target Node.js 20 but are being
forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the log of those two actions' steps, this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node 20 is being deprecated. This workflow is running with Node 24 by default.
If you need to temporarily use Node 20, you can set the
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same annotation appears on the very first run of that workflow, on 23 August. In other words, my two &lt;code&gt;node20&lt;/code&gt; actions have never run on Node 20 in this CI, and I didn't touch anything to make that happen. 23 September doesn't change their runtime, because that change already happened in June. Bumping them to &lt;code&gt;v5&lt;/code&gt; or later clears the annotation, and it's worth doing, but there's no clock on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it looks like when it does break
&lt;/h3&gt;

&lt;p&gt;There are three real ways to fail, and none of them is a GitHub message saying "Node 20 no longer exists."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You had the variable set.&lt;/strong&gt; Some action of yours tripped on Node 24 after 16 June, you set &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true&lt;/code&gt;, and it stayed on Node 20. In phase 3 the runner &lt;strong&gt;ignores the variable without telling you&lt;/strong&gt;: the &lt;code&gt;requireNode24&lt;/code&gt; branch returns &lt;code&gt;null&lt;/code&gt; as its message. The action goes back to Node 24, and the error you see is its own code's error, the same one you saw the first time. One hint that phase 3 is live: with the current runner code, the "If you need to temporarily use Node 20…" line disappears from the log, because it is only printed while Node 24 is the default but not yet mandatory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The action uses something Node 24 removed.&lt;/strong&gt; Per the &lt;a href="https://nodejs.org/docs/latest-v24.x/api/deprecations.html" rel="noopener noreferrer"&gt;Node 24 deprecations table&lt;/a&gt; and the &lt;a href="https://nodejs.org/en/blog/release/v24.0.0" rel="noopener noreferrer"&gt;24.0.0 release notes&lt;/a&gt;, these reached end-of-life in that release:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Status in Node 24&lt;/th&gt;
&lt;th&gt;How it fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tls.createSecurePair()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed (DEP0064)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TypeError: ... is not a function&lt;/code&gt; when called&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fs.truncate()&lt;/code&gt; with a file descriptor&lt;/td&gt;
&lt;td&gt;Removed (DEP0081)&lt;/td&gt;
&lt;td&gt;Throws &lt;code&gt;ERR_INVALID_ARG_TYPE&lt;/code&gt;: it expects a path. Use &lt;code&gt;fs.ftruncate()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dirent.path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed (DEP0178)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Doesn't throw on its own&lt;/strong&gt;: the property is gone and you read &lt;code&gt;undefined&lt;/code&gt;. Use &lt;code&gt;dirent.parentPath&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;OutgoingMessage.prototype._headers&lt;/code&gt; / &lt;code&gt;_headerNames&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Removed (DEP0066)&lt;/td&gt;
&lt;td&gt;You read &lt;code&gt;undefined&lt;/code&gt;. Use &lt;code&gt;getHeaders()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;dirent.path&lt;/code&gt; is the sneakiest one on the list, because reading it throws nothing: the failure shows up later, wherever that path gets used, or never, if it's only concatenated into a string and comes out as &lt;code&gt;undefined/file&lt;/code&gt;. If you maintain an action that walks directories, search for that property before you trust the green check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your self-hosted runner can't run Node 24.&lt;/strong&gt; On Linux ARM32, the same &lt;code&gt;NodeUtil.cs&lt;/code&gt; has a kill switch that fails the step with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Linux ARM32 runners are no longer supported. Please migrate to a supported platform.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before it's flipped, the runner keeps those actions on Node 20 with a warning whose default date is the Node 20 removal date: "Linux ARM32 runners are deprecated and will no longer be supported after September 23rd, 2026." GitHub hasn't published the exact day the switch flips; the changelog only says ARM32 loses support after the Node 20 deprecation. On macOS, &lt;a href="https://github.com/nodejs/node/blob/v24.x/BUILDING.md" rel="noopener noreferrer"&gt;Node 24's &lt;code&gt;BUILDING.md&lt;/code&gt;&lt;/a&gt; requires macOS 13.5 or later.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The audit: what to look for and where
&lt;/h2&gt;

&lt;h3&gt;
  
  
  In one repository
&lt;/h3&gt;

&lt;p&gt;Run this from the root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. The switch that stops working on 23 September&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION'&lt;/span&gt; .github/

&lt;span class="c"&gt;# 2. Image labels with a date. No "runs-on:" on purpose,&lt;/span&gt;
&lt;span class="c"&gt;#    so matrices show up too (os: [ubuntu-22.04, ...])&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'ubuntu-22\.04|macos-14|windows-11-arm'&lt;/span&gt; .github/

&lt;span class="c"&gt;# 3. Your own or local actions that declare an old Node&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;action.yml &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;action.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"using:[[:space:]]*['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]?node(12|16|20)"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search 2 has a blind spot: &lt;code&gt;runs-on: ${{ vars.RUNNER }}&lt;/code&gt; or a label passed as an input to a reusable workflow won't show up in any &lt;code&gt;grep&lt;/code&gt;. For those, the log is the source of truth. The "Set up job" step prints the real image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh run view &amp;lt;run-id&amp;gt; &lt;span class="nt"&gt;--log&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m1&lt;/span&gt; &lt;span class="s1"&gt;'Image:'&lt;/span&gt;
&lt;span class="c"&gt;# Image: ubuntu-24.04&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the matrix is long or nested, converting it to JSON and filtering it with &lt;code&gt;jq&lt;/code&gt; is usually faster than reading the YAML by eye:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/json-yaml-converter/" rel="noopener noreferrer"&gt;JSON and YAML Converter&lt;/a&gt; — Convert JSON to YAML and YAML to JSON instantly, with configurable indentation, download and copy. All in your browser, nothing uploaded.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To find out which runtime each third-party action you use declares, including SHA-pinned ones, you have to read its &lt;code&gt;action.yml&lt;/code&gt; at that exact ref. This script does it with &lt;code&gt;gh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s2"&gt;"uses:[[:space:]]*['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]?[^[:space:]'&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;#]+@[^[:space:]'&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;#]+"&lt;/span&gt; .github/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"s/uses:[[:space:]]*['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]?//"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; ref&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      &lt;/span&gt;&lt;span class="nv"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;%@*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;ver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;#*@&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$spec&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; ./&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;docker://&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
      &lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f1-2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$spec&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f3-&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$spec&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;action.yml action.yaml&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        &lt;/span&gt;&lt;span class="nv"&gt;rt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt;/contents/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;sub&lt;/span&gt;:+&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;?ref=&lt;/span&gt;&lt;span class="nv"&gt;$ver&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; .content 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
             | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^[[:space:]]*using:'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;" '&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rt&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="nv"&gt;$ref&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;done
    done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this repo it prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node20  actions/checkout@v4
node20  actions/setup-node@v4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It handles subpaths (&lt;code&gt;github/codeql-action/init@v3&lt;/code&gt;) and SHAs. A SHA pointing at a &lt;code&gt;node20&lt;/code&gt; release &lt;strong&gt;doesn't fail on the 23rd&lt;/strong&gt;, for the same reason &lt;code&gt;@v4&lt;/code&gt; doesn't: the runner forces it onto Node 24. What's different with a SHA is how it gets updated: nothing moves on its own, so Dependabot or a human has to bump it.&lt;/p&gt;

&lt;p&gt;One warning before bumping a major: read the release notes. &lt;code&gt;actions/setup-node&lt;/code&gt; v5 &lt;a href="https://github.com/actions/setup-node/releases/tag/v5.0.0" rel="noopener noreferrer"&gt;turned on automatic caching&lt;/a&gt; when &lt;code&gt;package.json&lt;/code&gt; has a &lt;code&gt;packageManager&lt;/code&gt; field, and v6 &lt;a href="https://github.com/actions/setup-node/releases/tag/v6.0.0" rel="noopener noreferrer"&gt;limited it to npm&lt;/a&gt;. If you already pass &lt;code&gt;cache: npm&lt;/code&gt; explicitly, as my CI does, it doesn't affect you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Across an organization
&lt;/h3&gt;

&lt;p&gt;GitHub code search reaches every repo at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh search code &lt;span class="nt"&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class="s1"&gt;'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION'&lt;/span&gt;
gh search code &lt;span class="nt"&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class="s1"&gt;'ubuntu-22.04 path:.github/workflows'&lt;/span&gt;
gh search code &lt;span class="nt"&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class="s1"&gt;'macos-14 path:.github/workflows'&lt;/span&gt;
gh search code &lt;span class="nt"&gt;--owner&lt;/span&gt; YOUR_ORG node20 &lt;span class="nt"&gt;--filename&lt;/span&gt; action.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two limits of the &lt;a href="https://docs.github.com/en/rest/search/search#search-code" rel="noopener noreferrer"&gt;code search API&lt;/a&gt; that will bite you: &lt;strong&gt;it only searches the default branch&lt;/strong&gt; (a workflow on a release branch won't show up) and it allows &lt;strong&gt;10 requests per minute&lt;/strong&gt;. While preparing this post I hit &lt;code&gt;HTTP 403: API rate limit exceeded&lt;/code&gt; as soon as I chained a handful of searches; add a &lt;code&gt;sleep&lt;/code&gt; between them if you automate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Self-hosted runners: two dates in three days
&lt;/h2&gt;

&lt;p&gt;If you only use GitHub-hosted runners, skip this section. If you run your own, 25 September matters more than the 23rd.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.blog/changelog/2026-06-12-github-actions-minimum-version-enforcement-timeline-for-self-hosted-runners" rel="noopener noreferrer"&gt;12 June changelog post&lt;/a&gt; resumes enforcement of two rules on github.com:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;To register&lt;/strong&gt; (or re-register) a runner you need version &lt;strong&gt;2.329.0&lt;/strong&gt; or later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;To keep running jobs&lt;/strong&gt;, the runner must install each new release &lt;strong&gt;within 30 days&lt;/strong&gt; of its publication. "A runner pinned to 2.329.0 that never updates again will not pick up jobs."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full enforcement begins on &lt;strong&gt;25 September 2026&lt;/strong&gt; (the notice's table labels that date as GitHub Enterprise Cloud, and the same notice states the change applies to github.com), with brownouts first: on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern, out-of-date runners can neither register nor run jobs. What you see, per the notice, is that "workflows targeting unsupported runners may remain queued or fail."&lt;/p&gt;

&lt;p&gt;The way to find out when your version expires is an endpoint GitHub &lt;a href="https://github.blog/changelog/2026-09-03-github-actions-early-september-2026-updates" rel="noopener noreferrer"&gt;added on 3 September&lt;/a&gt;. I queried it today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/OWNER/REPO/actions/runners/deprecations/2.335.1
&lt;span class="c"&gt;# {"runner_version":"2.335.1","runtime_deprecates_at":"2026-09-24T15:30:55Z"}&lt;/span&gt;

gh api repos/OWNER/REPO/actions/runners/deprecations/2.337.0
&lt;span class="c"&gt;# {"runner_version":"2.337.0","runtime_deprecates_at":null}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A runner on 2.335.1 falls out of support on 24 September, so by the time full enforcement starts on the 25th it no longer gets jobs. You get the installed version on the machine itself with &lt;code&gt;./config.sh --version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The self-hosted checklist, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update the runner&lt;/strong&gt; to the current release and leave auto-update on. If it's off, you need a manual cadence shorter than 30 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild the images, VM templates and containers&lt;/strong&gt; you create runners from. The changelog asks you to recreate runners built from older cached images or templates: if the image ships a runner older than 2.329.0, it won't register.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux ARM32:&lt;/strong&gt; move to arm64. Node 24 has no official support there, the runner's &lt;code&gt;linux-arm&lt;/code&gt; package only bundles the Node 20 binary (&lt;a href="https://github.com/actions/runner/blob/main/src/Misc/externals.sh" rel="noopener noreferrer"&gt;&lt;code&gt;externals.sh&lt;/code&gt;&lt;/a&gt;), and the code that fails the step is already in the runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS 13.4 or earlier:&lt;/strong&gt; upgrade the OS. GitHub lists Node 24 as incompatible with those versions.&lt;/li&gt;
&lt;li&gt;Actions that already declare &lt;code&gt;node24&lt;/code&gt; (such as &lt;code&gt;actions/checkout@v5&lt;/code&gt;) require runner &lt;a href="https://github.com/actions/checkout/releases/tag/v5.0.0" rel="noopener noreferrer"&gt;v2.327.1&lt;/a&gt; at minimum. If you've done step 1, you already have it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  4. Retention, 1 October: history gets deleted
&lt;/h2&gt;

&lt;p&gt;This change breaks no build, but it deletes data. According to the &lt;a href="https://github.blog/changelog/2026-08-27-actions-retention-will-cover-checks-workflow-runs-and-statuses" rel="noopener noreferrer"&gt;27 August changelog post&lt;/a&gt;, starting &lt;strong&gt;1 October 2026&lt;/strong&gt; checks, workflow runs and statuses follow the same retention setting that already governed artifacts and logs, &lt;strong&gt;90 days by default&lt;/strong&gt;. Until now they were kept for "400+ days" regardless of your configuration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For public repositories the maximum is &lt;strong&gt;90 days&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The change &lt;strong&gt;is not retroactive&lt;/strong&gt;: raising retention later won't restore anything.&lt;/li&gt;
&lt;li&gt;Check and workflow run metadata isn't billed as storage; the associated artifacts and logs are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What breaks is anything that reads that history beyond your period: deployment metrics computed from &lt;code&gt;gh run list&lt;/code&gt;, audits that link to a specific run, quarterly reports. Export what you need before 1 October.&lt;/p&gt;

&lt;p&gt;What the changelog does &lt;strong&gt;not&lt;/strong&gt; say: what happens to a pull request open for more than 90 days whose required check expires. I couldn't find it documented. If you have long-lived PRs under branch protection, look at them after 1 October before assuming anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Images: macos-14 has a date, ubuntu-22.04 doesn't yet
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;macos-14&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.com/actions/runner-images/issues/13518" rel="noopener noreferrer"&gt;actions/runner-images#13518&lt;/a&gt;): deprecation started on 6 July and support ends on &lt;strong&gt;2 November 2026&lt;/strong&gt;. Before that there are eight brownouts during which jobs fail: 5, 12, 16, 19, 23, 26, 29 and 30 October, each from 14:00 UTC to 00:00 UTC the next day. It covers &lt;code&gt;macos-14&lt;/code&gt;, &lt;code&gt;macos-14-large&lt;/code&gt; and &lt;code&gt;macos-14-xlarge&lt;/code&gt;. The replacement is &lt;code&gt;macos-15&lt;/code&gt;, &lt;code&gt;macos-26&lt;/code&gt; or &lt;code&gt;macos-latest&lt;/code&gt;, which &lt;a href="https://github.blog/changelog/2026-05-14-github-actions-upcoming-image-migrations" rel="noopener noreferrer"&gt;started pointing to macOS 26&lt;/a&gt; with the migration that began on 15 June.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ubuntu-22.04&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.com/actions/runner-images/issues/14254" rel="noopener noreferrer"&gt;actions/runner-images#14254&lt;/a&gt;): on 17 September &lt;strong&gt;deprecation only begins&lt;/strong&gt;. The issue warns about longer queue times at peak hours, not failures. Retirement is on &lt;strong&gt;17 April 2027&lt;/strong&gt;, with brownouts that the issue schedules for March and April (no year given, but they fall before the retirement). It also covers &lt;code&gt;ubuntu-22.04-arm&lt;/code&gt;. The replacement is &lt;code&gt;ubuntu-24.04&lt;/code&gt;, &lt;code&gt;ubuntu-26.04&lt;/code&gt; or &lt;code&gt;ubuntu-latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If someone tells you &lt;code&gt;ubuntu-22.04&lt;/code&gt; breaks this week, it doesn't. Migrate calmly, but don't leave it for March.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the changelog and breaks nothing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cache-mode&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode" rel="noopener noreferrer"&gt;10 September&lt;/a&gt;): a new key to limit cache access per workflow or job. It's opt-in: workflows that don't set it "continue to use the existing secure defaults."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The separate Code Quality path&lt;/strong&gt; (&lt;a href="https://github.blog/changelog/2026-08-20-separate-github-actions-path-for-github-code-quality" rel="noopener noreferrer"&gt;20 August&lt;/a&gt;): only affects you if you have reports filtering on &lt;code&gt;dynamic/github-code-scanning/codeql&lt;/code&gt; or on the &lt;code&gt;github-advanced-security&lt;/code&gt; actor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;actions/checkout&lt;/code&gt; and &lt;code&gt;pull_request_target&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout" rel="noopener noreferrer"&gt;18 June&lt;/a&gt;): this one does break, but it already happened. Since 20 July the floating tags, &lt;code&gt;@v4&lt;/code&gt; included, refuse to check out fork code in &lt;code&gt;pull_request_target&lt;/code&gt;. If your workflow depended on that, it's already red. It's the same family of risk I covered in &lt;a href="https://ortamarco.me/en/blog/npm-supply-chain-trusted-publishing-not-enough/" rel="noopener noreferrer"&gt;the worm that got in through npm trusted publishing&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'm doing with my CI
&lt;/h2&gt;

&lt;p&gt;Nothing urgent. &lt;code&gt;actions/checkout@v4&lt;/code&gt; and &lt;code&gt;actions/setup-node@v4&lt;/code&gt; have been running on Node 24 since June, the workflow doesn't use the opt-out variable, it runs on &lt;code&gt;ubuntu-latest&lt;/code&gt; (currently &lt;code&gt;ubuntu-24.04&lt;/code&gt;) and on GitHub-hosted runners. 23 September changes nothing for it.&lt;/p&gt;

&lt;p&gt;I am going to bump both actions to their current major, for two reasons unrelated to the date: a permanent yellow annotation trains you to ignore annotations, and an action its authors no longer test on the runtime it actually runs on is debt. If you're also thinking about your own project's Node rather than the actions', I covered that in &lt;a href="https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/" rel="noopener noreferrer"&gt;what breaks when upgrading to Node 26&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happens to GitHub Actions on 23 September 2026?
&lt;/h3&gt;

&lt;p&gt;GitHub removes Node 20 from the runners. Since 16 June 2026 runners had already been running actions on Node 24 by default, and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true let you go back to Node 20. From 23 September that variable stops working and every JavaScript action runs on Node 24. Self-hosted runners on Linux ARM32 lose support, because Node 24 has no official support for that platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an action that declares using: node20 fail after 23 September?
&lt;/h3&gt;

&lt;p&gt;Not for declaring it. The runner code (HandlerFactory.cs and NodeUtil.cs in actions/runner) rewrites node12 and node16 to node20, and node20 to node24; in the final phase it ignores the environment variables and always picks Node 24, without returning an error. The action fails if its code uses something Node 24 removed, such as tls.createSecurePair or fs.truncate with a file descriptor, or if it runs on a self-hosted Linux ARM32 runner or on macOS 13.4 or earlier. The logs show the annotation: The following actions target Node.js 20 but are being forced to run on Node.js 24.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to update actions/checkout@v4 and actions/setup-node@v4?
&lt;/h3&gt;

&lt;p&gt;It's a good idea, but no date forces it. The v4 tags of both declare node20 and v5 onward declare node24. On GitHub-hosted runners, the v4 tags have already been running on Node 24 since June and keep working. When bumping setup-node, read the release notes: v5 turned on automatic caching when package.json has packageManager, and v6 limited it to npm.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find affected workflows across my organization?
&lt;/h3&gt;

&lt;p&gt;With GitHub code search: gh search code --owner YOUR_ORG with ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, with ubuntu-22.04 path:.github/workflows, with macos-14 path:.github/workflows, and with node20 --filename action.yml for your own actions. The API only searches the default branch and allows 10 requests per minute. Labels that come from variables or expressions don't show up in any search: for those, check the Image: line of the Set up job step in the log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does ubuntu-22.04 break on 17 September 2026?
&lt;/h3&gt;

&lt;p&gt;No. On 17 September deprecation only begins, and the notice talks about longer queue times at peak hours. Jobs start failing during the brownouts that issue 14254 in actions/runner-images schedules for March and April, before the image is retired on 17 April 2027. The one with a date this fall is macos-14: brownouts in October and retirement on 2 November 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  What do self-hosted runners need to do before 25 September?
&lt;/h3&gt;

&lt;p&gt;Update the runner. From 25 September 2026 GitHub requires version 2.329.0 to register a runner, and each new release must be installed within 30 days to keep receiving jobs. There are brownouts first on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern. The endpoint GET /repos/OWNER/REPO/actions/runners/deprecations/VERSION returns when your version stops running jobs. Also rebuild the images you create runners from.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changes with GitHub Actions retention on 1 October 2026?
&lt;/h3&gt;

&lt;p&gt;Checks, workflow runs and statuses start following the artifact and log retention setting, which defaults to 90 days; before, they were kept for more than 400 days. For public repositories the maximum is 90 days, and the change is not retroactive. If you have metrics or audits that read old runs, export the data before that date.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/what-breaks-github-actions-fall-2026/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>devops</category>
      <category>cicd</category>
      <category>node</category>
    </item>
    <item>
      <title>npm Supply Chain: The Worm That Walked In Through Trusted Publishing</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:22:19 +0000</pubDate>
      <link>https://dev.to/marco_odev/npm-supply-chain-the-worm-that-walked-in-through-trusted-publishing-4pm8</link>
      <guid>https://dev.to/marco_odev/npm-supply-chain-the-worm-that-walked-in-through-trusted-publishing-4pm8</guid>
      <description>&lt;p&gt;&lt;strong&gt;For a year, npm security advice was a single sentence and everybody repeated it: stop publishing with long-lived tokens and move to &lt;em&gt;trusted publishing&lt;/em&gt; with OIDC. It was good advice. It still is. And on May 11, 2026, the Shai-Hulud worm published dozens of official &lt;code&gt;@tanstack/*&lt;/code&gt; packages by walking in exactly that way&lt;/strong&gt; — not by forging provenance, but by genuinely generating it from the project's own legitimate workflow.&lt;/p&gt;

&lt;p&gt;That distinction is the whole article. A malicious package with an authentic Sigstore signature, issued by Fulcio, logged in Rekor, backed by the real repository context. Every indicator you were taught to check said "fine."&lt;/p&gt;

&lt;p&gt;This doesn't mean trusted publishing is useless, or that anyone should go back to tokens. It means something more uncomfortable: &lt;strong&gt;it's a layer, not a perimeter&lt;/strong&gt;, and we spent a year selling it as the second thing. Here's what it actually protects, what it leaves out, and the four measures that would have stopped the last twelve months of attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline, with dates
&lt;/h2&gt;

&lt;p&gt;Worth having in front of you, because the cadence matters more than any single incident:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jul 2025&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Trusted publishing with OIDC&lt;/strong&gt; goes generally available on npm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 26, 2025&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;S1ngularity&lt;/strong&gt; attack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 8, 2025&lt;/td&gt;
&lt;td&gt;Phishing against &lt;code&gt;debug&lt;/code&gt;, &lt;code&gt;chalk&lt;/code&gt; and 16 more utilities — &lt;strong&gt;live for two hours&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 15, 2025&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Shai-Hulud wave 1&lt;/strong&gt;: 700+ packages compromised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nov 23, 2025&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Shai-Hulud 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dec 9, 2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;npm &lt;strong&gt;permanently revokes every classic token&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feb 2026&lt;/td&gt;
&lt;td&gt;npm CLI &lt;strong&gt;11.10.0&lt;/strong&gt; ships &lt;code&gt;min-release-age&lt;/code&gt; and bulk OIDC configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 30, 2026&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;axios@1.14.1&lt;/code&gt;&lt;/strong&gt; compromised — 100M weekly downloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;May 11, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Shai-Hulud against TanStack&lt;/strong&gt;, entering through trusted publishing itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May 14, 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;node-ipc&lt;/code&gt;: three malicious versions with an 80 KB credential stealer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jun 1, 2026&lt;/td&gt;
&lt;td&gt;32 &lt;code&gt;@redhat-cloud-services&lt;/code&gt; packages, payload named "Miasma"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 2026&lt;/td&gt;
&lt;td&gt;New wave: &lt;code&gt;keyv&lt;/code&gt; and 400+ packages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten months, eight campaigns. &lt;strong&gt;This is no longer a series of incidents: it's the registry's normal operating state.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it came in through the good door
&lt;/h2&gt;

&lt;p&gt;The TanStack attack is worth following step by step, because each link dismantles a different assumption. &lt;a href="https://www.upwind.io/feed/shai-hulud-tanstack-supply-chain-worm" rel="noopener noreferrer"&gt;Upwind's reconstruction&lt;/a&gt; lays it out like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A "dropper" dependency&lt;/strong&gt; lands in &lt;code&gt;package.json&lt;/code&gt; and runs during installation via npm's lifecycle hooks. Nothing exotic: that's documented behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dropper downloads and runs the Bun runtime.&lt;/strong&gt; That way it doesn't depend on the installed Node and it sidesteps monitoring that watches for Node executions.&lt;/li&gt;
&lt;li&gt;Under Bun runs &lt;strong&gt;a heavily obfuscated 2.3 MB payload&lt;/strong&gt; (&lt;code&gt;router_init.js&lt;/code&gt;) that daemonises itself and stays alive after the install finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential sweep&lt;/strong&gt;: filesystem, cloud APIs, and — this is the good part — &lt;strong&gt;GitHub Actions runner memory, scraped from &lt;code&gt;/proc/*/mem&lt;/code&gt;&lt;/strong&gt;. Targets: AWS keys, npm tokens, Kubernetes service accounts, AI assistant configs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OIDC abuse&lt;/strong&gt;: rather than needing a stored npm token, it &lt;strong&gt;exchanges the GitHub Actions OIDC token for dynamic publish access&lt;/strong&gt;. The workflow's &lt;code&gt;id-token: write&lt;/code&gt; permission is the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propagation&lt;/strong&gt;: with that access it downloads target packages, injects itself, &lt;strong&gt;bumps the version by exactly +3&lt;/strong&gt; and republishes.&lt;/li&gt;
&lt;li&gt;And along the way it &lt;strong&gt;mints a Sigstore bundle through Fulcio and Rekor&lt;/strong&gt; — so the malicious tarball ships with provenance generated from the trusted workflow context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 7 is the one to internalise. &lt;strong&gt;There was no forgery.&lt;/strong&gt; The chain of trust worked exactly as designed; what was compromised was the link before it, the CI runner. Provenance answers "was this built where it says?", and the answer was yes. It does not answer "is what was built what the maintainer wrote?", which was the question that mattered.&lt;/p&gt;

&lt;p&gt;The published indicators, in case you need to sweep: workflows talking to &lt;code&gt;api.masscan[.]cloud&lt;/code&gt; or &lt;code&gt;83.142.209.194&lt;/code&gt;, and persistence artifacts named &lt;code&gt;router_init.js&lt;/code&gt;, &lt;code&gt;pgmonitor.py&lt;/code&gt; or &lt;code&gt;pgsql-monitor.service&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is trusted publishing worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, very much so. But the split is worth being explicit about, because the generic advice never is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat&lt;/th&gt;
&lt;th&gt;Does OIDC stop it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm token stolen off a laptop&lt;/td&gt;
&lt;td&gt;✅ Yes — there's no token to steal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token leaked in a CI log or a &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintainer phishing (the &lt;code&gt;debug&lt;/code&gt;/&lt;code&gt;chalk&lt;/code&gt; case)&lt;/td&gt;
&lt;td&gt;✅ Largely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token still valid months after a maintainer leaves&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI runner compromised during the build&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⛔ &lt;strong&gt;No&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Malicious dependency running in &lt;code&gt;postinstall&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⛔ &lt;strong&gt;No&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI cache poisoning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⛔ &lt;strong&gt;No&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintainer's GitHub account compromised&lt;/td&gt;
&lt;td&gt;⛔ No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The correct reading: &lt;strong&gt;OIDC eliminates the long-lived secret, which was the dominant vector through 2025. It does not defend the process that issues the publish.&lt;/strong&gt; And as the first vector closes, attacks move to the second. That is precisely what the timeline above shows.&lt;/p&gt;

&lt;p&gt;One number to calibrate how many people are still in the first box: according to &lt;a href="https://www.aikido.dev/blog/shai-hulud-trusted-publishing" rel="noopener noreferrer"&gt;Aikido's analysis&lt;/a&gt;, of the 51,370 most-downloaded packages &lt;strong&gt;only 11,001 use trusted publishing&lt;/strong&gt; — 21.4%, covering barely 25% of download volume. &lt;strong&gt;Three quarters of what you install every day is still published with tokens.&lt;/strong&gt; And adoption moves incident by incident: from a baseline of ~35 new packages a week it spiked to 372 during Shai-Hulud 2.0, then the urgency deflated again.&lt;/p&gt;

&lt;p&gt;So the message isn't "trusted publishing failed." It's &lt;strong&gt;"migrate anyway, and also do these four things."&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The four measures that would actually have stopped this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Dependency cooldown: don't install what shipped this morning
&lt;/h3&gt;

&lt;p&gt;This is by far the best impact-to-effort ratio available, and still the least used.&lt;/p&gt;

&lt;p&gt;The idea is offensively simple: &lt;strong&gt;tell your package manager to ignore versions published less than N days ago&lt;/strong&gt;. Almost all of these campaigns are detected and pulled within hours — the malicious &lt;code&gt;debug&lt;/code&gt; and &lt;code&gt;chalk&lt;/code&gt; versions were live for &lt;strong&gt;two hours&lt;/strong&gt; — so a modest delay takes you entirely out of the exposure window. Of ten attacks analysed, &lt;a href="https://christian-schneider.net/blog/dependency-cooldowns-supply-chain-defense/" rel="noopener noreferrer"&gt;eight had windows shorter than a week&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All four managers support it natively now, with the trap that &lt;strong&gt;the units don't match&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Manager&lt;/th&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Since&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;&lt;code&gt;min-release-age&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;days&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CLI 11.10.0 (Feb 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pnpm&lt;/td&gt;
&lt;td&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;minutes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.16 (Sep 2025)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yarn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npmMinimalAgeGate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;minutes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Berry 4.10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bun&lt;/td&gt;
&lt;td&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;seconds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In npm, one line in &lt;code&gt;.npmrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;min-release-age&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In pnpm, inside &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, with an exclusion list for whatever you publish yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;minimumReleaseAge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10080&lt;/span&gt;          &lt;span class="c1"&gt;# 7 days in minutes&lt;/span&gt;
&lt;span class="na"&gt;minimumReleaseAgeExclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;@my-company/*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical warnings. First: &lt;strong&gt;the cooldown is enforced at install time, not at update-suggestion time&lt;/strong&gt;, so your dependency bot can open the PR and the &lt;code&gt;npm ci&lt;/code&gt; fail afterwards; set the same threshold in both places. Second: &lt;strong&gt;Bun counts seconds, not minutes&lt;/strong&gt;: seven days is &lt;code&gt;604800&lt;/code&gt;, and anyone who copies pnpm's &lt;code&gt;10080&lt;/code&gt; ends up with a cooldown of under three hours without noticing. Yarn does accept durations like &lt;code&gt;7d&lt;/code&gt;, and its exemption list is not a &lt;code&gt;*Exclude&lt;/code&gt; key but &lt;code&gt;npmPreapprovedPackages&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you want to see what it would have held back in your project before switching it on, &lt;a href="https://ortamarco.me/en/portfolio/dep-cooldown/" rel="noopener noreferrer"&gt;dep-cooldown&lt;/a&gt;, an open-source tool of mine, reads your npm, pnpm, Yarn or Bun lockfile and gives you the age of every resolved version, including on the day you installed (&lt;code&gt;--as-of&lt;/code&gt;). And &lt;code&gt;npx dep-cooldown --config all&lt;/code&gt; prints the key for all four managers with the unit already converted.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Separate the runner that installs from the runner that publishes
&lt;/h3&gt;

&lt;p&gt;This is the one that would have cut the TanStack attack dead.&lt;/p&gt;

&lt;p&gt;The structural problem is that in most workflows &lt;strong&gt;the same job runs &lt;code&gt;npm ci&lt;/code&gt; and &lt;code&gt;npm publish&lt;/code&gt;&lt;/strong&gt;. That means third-party code executing during install lives in the same process that holds — or can request — the OIDC token.&lt;/p&gt;

&lt;p&gt;The right shape is to split it, with &lt;code&gt;id-token: write&lt;/code&gt; existing &lt;strong&gt;only in the publishing job&lt;/strong&gt;, which installs nothing from third parties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;          &lt;span class="c1"&gt;# no id-token here&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci --ignore-scripts&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm pack&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;tarball&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*.tgz'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;         &lt;span class="c1"&gt;# the permission lives only here&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;tarball&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish *.tgz --provenance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;--ignore-scripts&lt;/code&gt; on the install step. Lifecycle hooks are steps 1 and 2 of the attack; if your build doesn't need them — and many don't — turning them off in CI is free. If some dependency genuinely does need them, the exception list is shorter than you think and worth writing by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Treat the CI cache as untrusted input
&lt;/h3&gt;

&lt;p&gt;The initial vector of the May wave was &lt;strong&gt;cache poisoning&lt;/strong&gt;. It's an unintuitive vector because the cache looks like your own infrastructure, and it isn't: in GitHub Actions, &lt;strong&gt;a lower-privileged branch can write into a cache that the release branch later reads&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never use &lt;code&gt;pull_request_target&lt;/code&gt; without an explicit trust boundary.&lt;/strong&gt; That trigger runs with the base repository's secrets and the PR's code in front of it; it's the foot in the door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segregate cache keys by branch or by purpose.&lt;/strong&gt; The release workflow should not share a key with the PR workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the publishing job, don't restore a cache at all.&lt;/strong&gt; That job should be as boring as possible: download an artifact, upload it. Nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Stop reading the provenance badge as a verdict
&lt;/h3&gt;

&lt;p&gt;Provenance is still useful: it tells you &lt;strong&gt;where&lt;/strong&gt; a package was built. What you can't do after May is read it as "this is safe."&lt;/p&gt;

&lt;p&gt;In practice that means dependency review doesn't get delegated to a badge. What does work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin with &lt;code&gt;package-lock.json&lt;/code&gt; and always use &lt;code&gt;npm ci&lt;/code&gt; in CI&lt;/strong&gt;, never &lt;code&gt;npm install&lt;/code&gt;. That's the difference between reproducing a tree and resolving a new one every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for odd version jumps.&lt;/strong&gt; The worm bumped versions by &lt;strong&gt;exactly +3&lt;/strong&gt;. A patch that skips three numbers with no changelog is a cheap and surprisingly good signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate credentials when a wave hits you&lt;/strong&gt;, and rotate everything: npm, GitHub, Actions secrets, AWS, Vault, Kubernetes, SSH. These payloads sweep the entire runner, not just the publish token.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/jwt-decoder/" rel="noopener noreferrer"&gt;JWT Decoder and Verifier&lt;/a&gt; — Decode the header and payload of a JSON Web Token and verify its HS256/384/512 or RS256/384/512 signature.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  And what npm changed on its own
&lt;/h2&gt;

&lt;p&gt;Part of the work has already been done for you, and it's worth knowing because it affects scripts you may still have lying around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classic tokens have been revoked since December 9, 2025.&lt;/strong&gt; Not deprecated: revoked. If an old pipeline of yours stopped publishing on that date, this is why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm login&lt;/code&gt; no longer hands you a long-lived token&lt;/strong&gt;, but a &lt;strong&gt;two-hour&lt;/strong&gt; session that expires on its own and enforces 2FA for publishing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granular write tokens expire after 90 days at most.&lt;/strong&gt; The eternal token no longer exists, even if you ask for it.&lt;/li&gt;
&lt;li&gt;And since npm CLI 11.10.0 you can &lt;strong&gt;configure OIDC in bulk&lt;/strong&gt; across many packages at once, which was the real friction in migrating an org with fifty packages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you publish packages, migrating to trusted publishing is literally four lines of YAML — the &lt;code&gt;id-token: write&lt;/code&gt; and the &lt;code&gt;--provenance&lt;/code&gt; from the example above — plus setting the trusted publisher on the package page. &lt;strong&gt;Do it.&lt;/strong&gt; But do it knowing you've just closed the 2025 vector, not the 2026 one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do this month, in order
&lt;/h2&gt;

&lt;p&gt;If you have half an hour, in decreasing order of return:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;min-release-age=7&lt;/code&gt;&lt;/strong&gt; (or your manager's equivalent) in every repository. Five minutes, cuts off most known campaigns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap &lt;code&gt;npm install&lt;/code&gt; for &lt;code&gt;npm ci --ignore-scripts&lt;/code&gt;&lt;/strong&gt; in every CI workflow that doesn't need hooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you publish packages, split the workflow&lt;/strong&gt; into build and publish, with &lt;code&gt;id-token: write&lt;/code&gt; only in the second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate to trusted publishing&lt;/strong&gt; anything still publishing with a granular token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your &lt;code&gt;pull_request_target&lt;/code&gt; triggers&lt;/strong&gt; and any cache keys shared across branches.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of the five is expensive. And here's the uncomfortable part: &lt;strong&gt;the first one, the most effective of all, requires trusting nobody and understanding no attack.&lt;/strong&gt; Just waiting a week.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/secure-vibe-coding-avoid-ai-code-vulnerabilities/" rel="noopener noreferrer"&gt;Secure Vibe Coding: Avoiding Vulnerabilities in AI-Written Code&lt;/a&gt; — the other front through which unreviewed code walks in.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/complete-docker-guide-2026/" rel="noopener noreferrer"&gt;The Complete Docker Guide for 2026&lt;/a&gt; — isolating the build is half the defence in this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/tools-to-audit-domain-security/" rel="noopener noreferrer"&gt;Tools to Audit Domain Security&lt;/a&gt; — the same review, pointed outward.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is trusted publishing with OIDC still worth it after the May 2026 attack?
&lt;/h3&gt;

&lt;p&gt;Yes, and you should migrate anyway. OIDC completely eliminates the long-lived token, which was the dominant vector through 2025: there is nothing to steal from a laptop, from a CI log, or from a maintainer via phishing. What it does not cover is the process that issues the publish: if the CI runner is compromised during the build, the attacker exchanges the OIDC token exactly as the legitimate workflow would. It is a layer, not a perimeter.&lt;/p&gt;

&lt;h3&gt;
  
  
  How did the Shai-Hulud worm generate valid Sigstore provenance?
&lt;/h3&gt;

&lt;p&gt;Because it did not forge it: it genuinely generated it. The malicious payload ran inside the project's own GitHub Actions runner, requested an OIDC token using the workflow's id-token: write permission, exchanged it for npm publish access, and minted a Sigstore bundle through Fulcio and Rekor from that trusted context. Provenance answers "was this built where it says?", and the answer was yes. It does not answer "is what was built what the maintainer wrote?".&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a dependency cooldown and how do I configure it?
&lt;/h3&gt;

&lt;p&gt;It tells your package manager to ignore versions published less than N days ago, so you never install a malicious version inside its lifetime, which is usually measured in hours. In npm it is min-release-age in .npmrc, measured in days (since CLI 11.10.0, February 2026); in pnpm it is minimumReleaseAge, measured in minutes (since 10.16); in Yarn it is npmMinimalAgeGate in minutes, and in Bun minimumReleaseAge in seconds. A seven-day threshold would have blocked eight of the ten most recent attacks analysed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why should the install job be separate from the publish job in CI?
&lt;/h3&gt;

&lt;p&gt;Because if the same job runs npm ci and npm publish, the third-party code that executes during installation lives in the same process that has access to the OIDC token. That is how the TanStack attack got in: a malicious dependency ran in a lifecycle hook, scraped the runner memory and exchanged the token. The fix is for id-token: write to exist only in a publish job that installs nothing from third parties and just uploads an already-built artifact.&lt;/p&gt;

&lt;h3&gt;
  
  
  What share of npm packages already use trusted publishing?
&lt;/h3&gt;

&lt;p&gt;Not many: of the 51,370 most-downloaded packages, 11,001 use it — 21.4%, covering around 25% of total download volume. In other words, three quarters of what gets installed daily is still published with tokens. Adoption spikes with each incident (372 new packages during the week of Shai-Hulud 2.0, against a baseline of about 35 per week) and falls back once the urgency passes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do npm classic tokens still work?
&lt;/h3&gt;

&lt;p&gt;No. npm permanently revoked every classic token on December 9, 2025. Since then npm login issues a two-hour session instead of a long-lived token, and granular write tokens expire after 90 days at most. If an old pipeline stopped publishing around those dates, that is the cause.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/npm-supply-chain-trusted-publishing-not-enough/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>npm</category>
      <category>javascript</category>
      <category>node</category>
    </item>
    <item>
      <title>Why Online Stores Crash During Mexico's Buen Fin (and How to Prevent It): The 2026 Technical Guide</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:22:16 +0000</pubDate>
      <link>https://dev.to/marco_odev/why-online-stores-crash-during-mexicos-buen-fin-and-how-to-prevent-it-the-2026-technical-guide-47g5</link>
      <guid>https://dev.to/marco_odev/why-online-stores-crash-during-mexicos-buen-fin-and-how-to-prevent-it-the-2026-technical-guide-47g5</guid>
      <description>&lt;p&gt;&lt;strong&gt;El Buen Fin, Mexico's answer to Black Friday, runs from Friday November 13 to Tuesday November 17, 2026. Almost everything published about getting a store ready for it is about banners, coupons and email. This guide is the other half: what breaks technically when the visitors you paid to attract all show up at once, and what to do beforehand. Nearly all of it is written down in your host's, your payment gateway's and your platform's documentation, and almost nobody reads it until the store is already down.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Last year's numbers show why it matters. According to Mexico's &lt;a href="https://www.gob.mx/se/prensa/supera-la-meta-la-xv-edicion-de-el-buen-fin-2025?idiom=es-MX" rel="noopener noreferrer"&gt;Ministry of Economy&lt;/a&gt;, the 2025 edition sold 219.2 billion pesos, and digital commerce was &lt;strong&gt;21% of the total, growing 31% year over year&lt;/strong&gt;. The &lt;a href="https://blog.amvo.org.mx/publicaciones/reporte-de-resultados-el-buen-fin-2025" rel="noopener noreferrer"&gt;public AMVO report&lt;/a&gt; (Mexico's online sales association) counted 1.686 billion page views on e-commerce sites and traffic &lt;strong&gt;56% higher&lt;/strong&gt; than the previous edition (which went from four days to five). The retailers' association ANTAD reports that the last day alone took 26.1% of online sales. The peak doesn't spread out: it arrives, and it arrives all at once.&lt;/p&gt;

&lt;p&gt;If you sell into Mexico from abroad, or build stores for Mexican clients, the local details are what catch people out: the cash-at-OXXO payment method, the Mexican shared-hosting plans small businesses actually run on, and a gateway landscape where Mercado Pago matters as much as Stripe. I've built WooCommerce stores with catalogues in the thousands, Mercado Pago payments and WhatsApp checkout. This is what I check on each one before a date like this, with a source for every limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What those five days look like
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Buen Fin 2025&lt;/th&gt;
&lt;th&gt;Figure&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total sales&lt;/td&gt;
&lt;td&gt;219.2 billion pesos&lt;/td&gt;
&lt;td&gt;Ministry of Economy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Online sales&lt;/td&gt;
&lt;td&gt;21% of the total, +31% YoY (45.9 billion per AMVO)&lt;/td&gt;
&lt;td&gt;Economy / AMVO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traffic to online stores&lt;/td&gt;
&lt;td&gt;+56% over the 5 days, +24% on comparable days&lt;/td&gt;
&lt;td&gt;AMVO (Similarweb data)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average ticket&lt;/td&gt;
&lt;td&gt;$1,063 MXN online vs $780 overall&lt;/td&gt;
&lt;td&gt;Mexican Banking Association, cited by Economy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How people paid online&lt;/td&gt;
&lt;td&gt;Credit 48%, debit 43%, store cards 17%, card-free instalments 12%, cash at retail chains 12%&lt;/td&gt;
&lt;td&gt;AMVO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer agency complaints&lt;/td&gt;
&lt;td&gt;220 filed, 205 settled; mostly unhonoured prices and refused delivery&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.gob.mx/profeco/prensa/profeco-intermedia-la-devolucion-de-mas-de-un-millon-de-pesos-a-personas-consumidoras-en-el-buen-fin?idiom=es-MX" rel="noopener noreferrer"&gt;Profeco&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows matter for the technical side. One in eight online buyers &lt;strong&gt;paid cash at a retail chain&lt;/strong&gt; (OXXO and similar), which has a consequence for your inventory that I explain below. And 37% of consumers, per the AIMX survey the ministry cites, &lt;strong&gt;fear a data leak&lt;/strong&gt;: an expired-certificate warning or a checkout page that looks off on your busiest day costs sales even if the site stays up.&lt;/p&gt;

&lt;p&gt;Not everything that fails is yours, either. During Buen Fin 2024, Santander and BBVA customers reported card payment failures; during Hot Sale in May 2026 BBVA confirmed on X that its systems were down, with over five hours of Downdetector reports. And on November 18, 2025, the day after Buen Fin ended, Cloudflare had &lt;a href="https://blog.cloudflare.com/18-november-2025-outage/" rel="noopener noreferrer"&gt;a global outage&lt;/a&gt; of more than three hours. You can't stop your customer's bank from going down. You can have a second payment method ready when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Shared hosting has a ceiling, and it's in writing
&lt;/h2&gt;

&lt;p&gt;The shared hosting plan that costs a few hundred pesos a year isn't "slow": it has exact limits, and when you hit them it doesn't slow down, &lt;strong&gt;it returns errors&lt;/strong&gt;. Hostinger publishes its limits on its &lt;a href="https://www.hostinger.com/support/6976044-parameters-and-limits-of-hosting-plans-in-hostinger/" rel="noopener noreferrer"&gt;plan parameters page&lt;/a&gt; and says reaching them produces "503 – Service temporarily unavailable":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hostinger plan&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Concurrent PHP workers&lt;/th&gt;
&lt;th&gt;MySQL connections&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web Single&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 GB&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web Premium&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2 GB&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unlimited (formerly Business)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3 GB&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HostGator Mexico puts it differently in &lt;a href="https://soporte.hostgator.mx/hc/es-419/articles/28440886639635" rel="noopener noreferrer"&gt;its help centre&lt;/a&gt;: a cap of 25% CPU sustained for 90 seconds or more, and &lt;strong&gt;25 concurrent processes per cPanel account&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Do the maths with your own store. An uncached WooCommerce product page holds a PHP worker while it renders. If it takes one second and you have 20 workers, your ceiling is about 20 pages per second; visitor number 21 doesn't wait in line, they get an error. That's why caching, next, isn't an optimization: it decides whether those limits ever reach you.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Caching: what can be cached and what can't
&lt;/h2&gt;

&lt;p&gt;The idea is simple: most visitors, the ones just browsing, should get a prebuilt copy of the page and cost your server nothing. Only the cart, checkout and account pages need to reach the server every time.&lt;/p&gt;

&lt;p&gt;What almost nobody knows is that &lt;strong&gt;Cloudflare doesn't cache HTML by default&lt;/strong&gt;, on free or paid plans. Its &lt;a href="https://developers.cloudflare.com/cache/concepts/default-cache-behavior/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; is explicit: "The Cloudflare CDN does not cache HTML or JSON by default." If you put your store behind Cloudflare and configured nothing else, images, CSS and JavaScript are cached, but every page still hits your host. The free plan gives you up to 10 Cache Rules and one is enough. This expression is for a Spanish-language WooCommerce store (adjust the paths to yours):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vcl"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;http.host eq &lt;span class="s2"&gt;"tutienda.mx"&lt;/span&gt;
 and not starts_with&lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/wp-admin"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 and not http.request.uri.path contains &lt;span class="s2"&gt;"/carrito"&lt;/span&gt;
 and not http.request.uri.path contains &lt;span class="s2"&gt;"/finalizar-compra"&lt;/span&gt;
 and not http.request.uri.path contains &lt;span class="s2"&gt;"/mi-cuenta"&lt;/span&gt;
 and not http.cookie contains &lt;span class="s2"&gt;"woocommerce_items_in_cart"&lt;/span&gt;
 and not http.cookie contains &lt;span class="s2"&gt;"wordpress_logged_in"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the "Eligible for cache" action and a short edge TTL (5 to 10 minutes), browsers get the cached copy, and anyone with something in the cart or a logged-in session goes straight to your server. Two warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Purge the cache when prices change.&lt;/strong&gt; If your discounts start at midnight on the 13th, a copy cached at 11:58 pm still shows the old price, and "the price wasn't the advertised one" is among the complaints Profeco receives most. Purge when you switch the offers on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cookie on every page breaks everything.&lt;/strong&gt; Cloudflare won't cache a response carrying &lt;code&gt;Set-Cookie&lt;/code&gt;, and some plugins (currency switchers, geolocation, popups) send one on every visit. Check it like this:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://tutienda.mx/producto/tu-mas-vendido/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"set-cookie|cf-cache-status"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you want to see, from the second request on, is &lt;code&gt;cf-cache-status: HIT&lt;/code&gt; and no &lt;code&gt;set-cookie&lt;/code&gt;. If you see &lt;code&gt;DYNAMIC&lt;/code&gt; or &lt;code&gt;BYPASS&lt;/code&gt;, that page is still hitting your server.&lt;/p&gt;

&lt;p&gt;Two things look like safety nets and aren't. &lt;strong&gt;Always Online&lt;/strong&gt; is on every plan, but it only kicks in on 52x errors, serves Internet Archive snapshots and &lt;a href="https://developers.cloudflare.com/cache/how-to/always-online/" rel="noopener noreferrer"&gt;can't serve dynamic content&lt;/a&gt;: nobody checks out from an archived copy. And Cloudflare's &lt;strong&gt;Waiting Room&lt;/strong&gt;, the virtual queue big retailers use, &lt;a href="https://developers.cloudflare.com/waiting-room/plans/" rel="noopener noreferrer"&gt;only exists on Business and Enterprise plans&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On WooCommerce, also check &lt;em&gt;cart fragments&lt;/em&gt;: the script that updates the cart counter by calling the server on every page. &lt;a href="https://developer.woocommerce.com/2023/06/16/best-practices-for-the-use-of-the-cart-fragments-api/" rel="noopener noreferrer"&gt;WooCommerce acknowledges&lt;/a&gt; that on heavily trafficked stores "this could severely impact the load on the server", and since version 7.8 it no longer loads on every page, but plenty of themes and plugins turn it back on. Open a product page with your browser's dev tools and look for a request to &lt;code&gt;?wc-ajax=get_refreshed_fragments&lt;/code&gt;. If it fires on pages without a mini cart, that's one server request per visit that caching can't remove.&lt;/p&gt;

&lt;p&gt;And slim down your product photos: they're most of a store's page weight, and on mobile, where most Mexican shoppers buy, every megabyte shows.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/image-compressor/" rel="noopener noreferrer"&gt;Image Compressor&lt;/a&gt; — Compress and optimize JPG, PNG or WebP images without visible quality loss, with quality control, format change and resizing. All in your browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Your payment gateway has a limit too
&lt;/h2&gt;

&lt;p&gt;The server isn't the only thing that can say no. Stripe publishes its &lt;a href="https://docs.stripe.com/rate-limits" rel="noopener noreferrer"&gt;rate limits&lt;/a&gt;: &lt;strong&gt;100 requests per second&lt;/strong&gt; in live mode, with most individual endpoints capped at 25. And it says plainly that "a sudden increase in charge volume, such as a flash sale, might result in rate limiting"; if you expect a spike, it asks you to contact support &lt;strong&gt;beforehand&lt;/strong&gt;. Mercado Pago doesn't publish a number, but its Orders API answers &lt;code&gt;429 Too Many Requests&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header when you exceed it.&lt;/p&gt;

&lt;p&gt;For a small business, 100 charges a second is plenty. The problem shows up when the integration makes several calls per purchase (create customer, create payment intent, poll status) or retries without backing off: a badly handled 429 becomes "we couldn't process your payment" for a customer who had the funds.&lt;/p&gt;

&lt;p&gt;Bank verification (3-D Secure, the "confirm in your app" screen) changes the flow too. Mexico has no regulatory mandate for it, but Mercado Pago &lt;a href="https://www.mercadopago.com.mx/developers/es/docs/checkout-api-payments/how-tos/integrate-3ds" rel="noopener noreferrer"&gt;recommends enabling it&lt;/a&gt; in optional mode, and when the bank asks for a challenge the payment sits in &lt;code&gt;pending&lt;/code&gt; with detail &lt;code&gt;pending_challenge&lt;/code&gt;. If your integration treats "pending" as "declined", you're cancelling good orders. AMVO lists "payment method rejected" among the barriers to buying online.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The problem nobody mentions: cash at OXXO locks your inventory
&lt;/h2&gt;

&lt;p&gt;This is what worries me most for a store with limited stock, and I didn't see it in any Buen Fin guide I read. When a customer chooses to pay cash, the gateway issues a voucher with an expiry date, and until they pay, the item is in limbo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gateway&lt;/th&gt;
&lt;th&gt;Voucher validity&lt;/th&gt;
&lt;th&gt;If they pay late&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe (OXXO)&lt;/td&gt;
&lt;td&gt;5 days by default, configurable from 1 to 7; confirmation arrives the next business day&lt;/td&gt;
&lt;td&gt;The voucher expires (&lt;a href="https://docs.stripe.com/payments/oxxo" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mercado Pago (OXXO, Paycash)&lt;/td&gt;
&lt;td&gt;Configurable from 1 to 30 days; they recommend 3. Crediting takes up to 2 business hours&lt;/td&gt;
&lt;td&gt;"The amount will be refunded" (&lt;a href="https://www.mercadopago.com.mx/developers/es/docs/checkout-api-orders/payment-integration/other-payment-methods" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conekta&lt;/td&gt;
&lt;td&gt;Set by your integration with &lt;code&gt;expires_at&lt;/code&gt;; signalled by the &lt;code&gt;order.expired&lt;/code&gt; webhook&lt;/td&gt;
&lt;td&gt;Up to your integration (&lt;a href="https://developers.conekta.com/docs/order" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the WooCommerce side. Its "Hold stock" setting reserves items for orders &lt;strong&gt;pending payment&lt;/strong&gt; for as long as you configure (60 minutes is the recommended value) and then cancels them, according to &lt;a href="https://woocommerce.com/document/managing-orders/troubleshooting-orders/" rel="noopener noreferrer"&gt;its guide&lt;/a&gt;. But the same guide says delayed payment methods leave the order &lt;strong&gt;on hold&lt;/strong&gt;, where that automatic cancellation doesn't apply. If your gateway plugin doesn't cancel the order when the voucher expires, the item stays reserved for days by someone who may never pay, while you tell the customer holding a card that it's sold out.&lt;/p&gt;

&lt;p&gt;Before the 13th:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shorten voucher validity for the campaign.&lt;/strong&gt; One or two days instead of five or thirty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that your plugin cancels the order on expiry.&lt;/strong&gt; Place a test cash order with the minimum validity, don't pay it, and check the next day that the order was cancelled and the stock came back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you sell over WhatsApp with payment links&lt;/strong&gt;, give them a short expiry. Clip, for example, lets a link &lt;a href="https://developer.clip.mx/discuss/6480ac8b182697101358f435" rel="noopener noreferrer"&gt;expire the same day&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  5. Overselling: two people buy the last unit
&lt;/h2&gt;

&lt;p&gt;Since version 4.3, WooCommerce reserves stock during checkout to stop two simultaneous purchases taking the same unit (&lt;a href="https://github.com/woocommerce/woocommerce/pull/26395" rel="noopener noreferrer"&gt;PR #26395&lt;/a&gt;). Even so, &lt;a href="https://github.com/woocommerce/woocommerce/issues/44273" rel="noopener noreferrer"&gt;issue #44273&lt;/a&gt;, where the team confirms that "a race condition like this is possible", is still open today. On a normal day it almost never happens. During Buen Fin, with hundreds of people on your best seller, is when it does.&lt;/p&gt;

&lt;p&gt;For low-stock products: keep a buffer (list 18 if you have 20), cap quantity per customer, and decide in advance what you'll do if it happens, with an apology and a fast refund. Refused delivery was among the complaints that dominated Profeco's 2025 figures. Shopify's &lt;a href="https://help.shopify.com/en/manual/promoting-marketing/sales/flash-sales" rel="noopener noreferrer"&gt;flash sale guide&lt;/a&gt; recommends switching to manual payment capture for the same reason: charge only for what you can actually ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What runs in the background
&lt;/h2&gt;

&lt;p&gt;Order confirmation emails, payment webhooks and stock updates in WooCommerce go through Action Scheduler, which by default &lt;a href="https://actionscheduler.org/perf/" rel="noopener noreferrer"&gt;processes batches of 25 actions&lt;/a&gt; for 30 seconds, triggered by WP-Cron. And WP-Cron only runs when someone visits the site. With caching done right, hardly anyone reaches the server, and tasks can pile up. The result: customers who paid and don't get their confirmation, and message you asking whether the order went through.&lt;/p&gt;

&lt;p&gt;The fix is a real server cron instead of the visit-driven one: set &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; in &lt;code&gt;wp-config.php&lt;/code&gt; and schedule a call to &lt;code&gt;wp-cron.php&lt;/code&gt; every minute from your hosting panel. Almost every shared host allows it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Test it first with fake traffic
&lt;/h2&gt;

&lt;p&gt;All of the above can be checked without waiting for the 13th. A load test simulates many visitors at once and shows you where errors start. Two free options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;k6&lt;/strong&gt;, by Grafana: free on your own machine, and its free cloud tier includes &lt;a href="https://grafana.com/pricing/" rel="noopener noreferrer"&gt;500 virtual user hours a month&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loader.io&lt;/strong&gt;: the free plan allows &lt;a href="https://loader.io/pricing" rel="noopener noreferrer"&gt;10,000 clients per test&lt;/a&gt; for one minute, run from US data centres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal k6 script that visits the home page and your best seller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k6/http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// ramp to 50 concurrent visitors&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// hold&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;http_req_failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate&amp;lt;0.01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;     &lt;span class="c1"&gt;// under 1% errors&lt;/span&gt;
    &lt;span class="na"&gt;http_req_duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p(95)&amp;lt;2000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// 95% under 2 seconds&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://tutienda.mx/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;home&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://tutienda.mx/producto/tu-mas-vendido/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;k6 run test.js&lt;/code&gt;. Three rules: test pages, &lt;strong&gt;never your payment gateway&lt;/strong&gt;; on shared hosting, tell your provider first, because a load test looks a lot like an attack; and aim at three to five times your normal peak hour. There's no public figure for how much a small store's traffic rises during Buen Fin compared with an ordinary day; if your analytics kept last year's, use that. If the test fails at 50 visitors, you know what to fix, and you have seven weeks to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If your store runs on Shopify or Tiendanube
&lt;/h2&gt;

&lt;p&gt;Infrastructure isn't your problem: Shopify handled &lt;a href="https://www.shopify.com/investors/press-releases/shopify-merchants-achieve-record-breaking-14-6-billion-in-black-friday-cyber-monday-sales" rel="noopener noreferrer"&gt;489 million requests per minute&lt;/a&gt; at its edge over Black Friday and Cyber Monday 2025, and its guide says special preparation is only needed if you expect "tens of thousands of customers" starting checkout within minutes. Yours are the other sections: theme weight and the apps you load on every page, the gateway and its pending states, cash voucher validity, and your stock buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-weeks-before checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;How you verify it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML caching on for browsers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cf-cache-status: HIT&lt;/code&gt; on the second request to a product page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No page sends &lt;code&gt;Set-Cookie&lt;/code&gt; to visitors&lt;/td&gt;
&lt;td&gt;The same &lt;code&gt;curl -sI&lt;/code&gt; as above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cart, checkout and account excluded from cache&lt;/td&gt;
&lt;td&gt;Add something to the cart in another window and check the counter updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New prices visible&lt;/td&gt;
&lt;td&gt;Purge the cache when offers go live and check in a private window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short cash-voucher validity&lt;/td&gt;
&lt;td&gt;Test OXXO order you don't pay: on expiry, order cancelled and stock back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Pending" payments treated as pending&lt;/td&gt;
&lt;td&gt;Test purchase with 3-D Secure that you don't complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real server cron&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; in &lt;code&gt;wp-config.php&lt;/code&gt; and a task every minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buffer on best sellers&lt;/td&gt;
&lt;td&gt;Listed stock below real stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL certificate valid past November 17&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ortamarco.me/en/tools/domain-health-report/" rel="noopener noreferrer"&gt;Domain health report&lt;/a&gt; (HTTPS, redirect and HSTS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load test passed&lt;/td&gt;
&lt;td&gt;k6 or Loader.io with no errors at three times your peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan B for payments&lt;/td&gt;
&lt;td&gt;Payment link and WhatsApp ready in case the gateway or the bank fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you need a store or site in Mexico built to survive a date like this, that's &lt;a href="https://ortamarco.me/en/web-development-service/" rel="noopener noreferrer"&gt;what I do&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  When is Buen Fin 2026?
&lt;/h3&gt;

&lt;p&gt;From Friday November 13 to Tuesday November 17, 2026. It is the 16th edition and runs five days because it includes Monday November 16, the observed Revolution Day holiday. Business registration is free at elbuenfin.org, from September 8 to November 12.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do online stores crash during Buen Fin?
&lt;/h3&gt;

&lt;p&gt;Usually because every visit reaches the server uncached and the host runs out of the processes it allows. Shared plans have fixed limits (20 PHP workers on Hostinger's entry plan, 25 processes per cPanel account on HostGator Mexico) and return a 503 error past them. The payment gateway can also fail when it rate-limits, or the customer's bank, which is out of your hands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Cloudflare's free plan protect a store from Buen Fin traffic?
&lt;/h3&gt;

&lt;p&gt;It helps, but not just by switching it on. Cloudflare does not cache HTML by default, so without a Cache Rule every page still hits your host. The free plan allows up to 10 rules, and one is enough to cache pages for browsers while excluding cart, checkout and account. The virtual queue (Waiting Room) only exists on Business and Enterprise plans.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a pending OXXO payment lock my inventory?
&lt;/h3&gt;

&lt;p&gt;It can, for days. Stripe OXXO vouchers expire after 5 days by default (configurable from 1 to 7) and Mercado Pago's after 1 to 30. In WooCommerce, delayed-payment orders go on hold and the 60-minute hold-stock setting only cancels orders pending payment, so the item stays reserved until the gateway plugin cancels the order when the voucher expires. Shorten validity for the campaign and test it with an order you do not pay.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I load test an online store?
&lt;/h3&gt;

&lt;p&gt;With k6, free and open source, or Loader.io, whose free plan runs up to 10,000 clients in a one-minute test. Simulate visitors on the home page and best sellers, never on the payment gateway, aim at three to five times your peak hour, and warn your shared hosting provider first, because a load test looks like an attack.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/online-store-crash-buen-fin-mexico/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>woocommerce</category>
      <category>performance</category>
    </item>
    <item>
      <title>Laravel 13: What Actually Breaks When You Upgrade (and What Doesn't)</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:29:07 +0000</pubDate>
      <link>https://dev.to/marco_odev/laravel-13-what-actually-breaks-when-you-upgrade-and-what-doesnt-13d6</link>
      <guid>https://dev.to/marco_odev/laravel-13-what-actually-breaks-when-you-upgrade-and-what-doesnt-13d6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Laravel 12 stopped receiving bug fixes on August 13, 2026.&lt;/strong&gt; Since then only security patches remain, and those run out on February 24, 2027. If you have a production app on 12, you are already in the last window.&lt;/p&gt;

&lt;p&gt;The good news is that Laravel 13 really is a small upgrade. The official docs estimate &lt;strong&gt;10 minutes&lt;/strong&gt; and say most applications can upgrade "without changing much application code." That is true.&lt;/p&gt;

&lt;p&gt;The bad news is the nuance almost nobody is covering: &lt;em&gt;minimal breaking changes&lt;/em&gt; is not &lt;em&gt;zero breaking changes&lt;/em&gt;. The &lt;a href="https://laravel.com/docs/13.x/upgrade" rel="noopener noreferrer"&gt;official upgrade guide&lt;/a&gt; flags &lt;strong&gt;two high-impact changes&lt;/strong&gt;, two medium-impact ones, and roughly fifteen low-impact ones. And if your application caches PHP objects, uses &lt;code&gt;upsert&lt;/code&gt; on MySQL, or excludes the CSRF middleware in tests, one of them is going to bite.&lt;/p&gt;

&lt;p&gt;This article is the complete list, with the concrete code for each change. If what you need is the general upgrade procedure — backup, branch, &lt;code&gt;composer update&lt;/code&gt;, verification — that lives in the &lt;a href="https://ortamarco.me/en/blog/how-to-upgrade-laravel-9-to-laravel-10/" rel="noopener noreferrer"&gt;guide to upgrading Laravel to the latest version&lt;/a&gt;; here we go straight to what breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: where you are and how urgent this is
&lt;/h2&gt;

&lt;p&gt;This is the official support table, not the one making the rounds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Supported PHP&lt;/th&gt;
&lt;th&gt;Released&lt;/th&gt;
&lt;th&gt;Bug fixes until&lt;/th&gt;
&lt;th&gt;Security until&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Laravel 13&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8.3 – 8.5&lt;/td&gt;
&lt;td&gt;Mar 17, 2026&lt;/td&gt;
&lt;td&gt;Q3 2027&lt;/td&gt;
&lt;td&gt;Mar 17, 2028&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel 12&lt;/td&gt;
&lt;td&gt;8.2 – 8.5&lt;/td&gt;
&lt;td&gt;Feb 24, 2025&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Aug 13, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Feb 24, 2027&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel 11&lt;/td&gt;
&lt;td&gt;8.2 – 8.4&lt;/td&gt;
&lt;td&gt;Mar 12, 2024&lt;/td&gt;
&lt;td&gt;Sep 3, 2025&lt;/td&gt;
&lt;td&gt;Mar 12, 2026 ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel 10&lt;/td&gt;
&lt;td&gt;8.1 – 8.3&lt;/td&gt;
&lt;td&gt;Feb 14, 2023&lt;/td&gt;
&lt;td&gt;Aug 6, 2024&lt;/td&gt;
&lt;td&gt;Feb 4, 2025 ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth reading carefully in that table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laravel 11 has received no security patches since March 12, 2026.&lt;/strong&gt; If you're there, you aren't "a little behind" — you're uncovered.&lt;/li&gt;
&lt;li&gt;Laravel 12 &lt;strong&gt;is not LTS&lt;/strong&gt;. Laravel stopped shipping LTS releases years ago; the current policy is uniform across every major — 18 months of bug fixes and 2 years of security.
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    A["Which version are you on?"] --&amp;gt; B["Laravel 12"]
    A --&amp;gt; C["Laravel 11"]
    A --&amp;gt; D["Laravel 10 or older"]
    B --&amp;gt; B1["✅ Jump straight to 13&amp;lt;br/&amp;gt;Read this list and do it"]
    C --&amp;gt; C1["⚠️ No security patches&amp;lt;br/&amp;gt;since Mar 12, 2026"]
    C1 --&amp;gt; C2["Go 11 → 12 → 13,&amp;lt;br/&amp;gt;one major at a time"]
    D --&amp;gt; D1["🚨 Fully end of life"]
    D1 --&amp;gt; C2&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Entry requirement: PHP 8.3
&lt;/h2&gt;

&lt;p&gt;Laravel 13 requires &lt;strong&gt;PHP 8.3 minimum&lt;/strong&gt; and supports up to 8.5. No more, no less: if you read somewhere that "in practice it needs 8.4," that's wrong — 8.3 works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;          &lt;span class="c"&gt;# you need 8.3.0 or higher&lt;/span&gt;
composer &lt;span class="nt"&gt;-V&lt;/span&gt;     &lt;span class="c"&gt;# and an up-to-date Composer 2.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're coming from Laravel 12 on PHP 8.2, &lt;strong&gt;this is the only real infrastructure work&lt;/strong&gt; in the upgrade. Do it before touching &lt;code&gt;composer.json&lt;/code&gt;, not at the same time, so that if something breaks you know what broke it.&lt;/p&gt;




&lt;h2&gt;
  
  
  High impact
&lt;/h2&gt;

&lt;p&gt;There are two. Both affect practically every application.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Dependencies in &lt;code&gt;composer.json&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;No surprises, but there are more packages than people remember:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"php"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^8.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"laravel/framework"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"laravel/tinker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"require-dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"laravel/boost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phpunit/phpunit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^12.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pestphp/pest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^4.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two most commonly forgotten are &lt;strong&gt;&lt;code&gt;laravel/tinker&lt;/code&gt; to &lt;code&gt;^3.0&lt;/code&gt;&lt;/strong&gt; and the jump to &lt;strong&gt;PHPUnit &lt;code&gt;^12.0&lt;/code&gt;&lt;/strong&gt;. If you use Pest, you need &lt;code&gt;^4.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And if you installed the global Laravel installer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer global update laravel/installer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Herd, updating Herd is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The CSRF middleware changes name and behavior
&lt;/h3&gt;

&lt;p&gt;This is the significant change in the release, and the one most likely to quietly break your tests.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VerifyCsrfToken&lt;/code&gt; is renamed to &lt;strong&gt;&lt;code&gt;PreventRequestForgery&lt;/code&gt;&lt;/strong&gt;, and it isn't just a rename: it now performs &lt;strong&gt;origin verification&lt;/strong&gt; by reading the browser's &lt;code&gt;Sec-Fetch-Site&lt;/code&gt; header, on top of the usual token validation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Http\Middleware\PreventRequestForgery&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Foundation\Http\Middleware\VerifyCsrfToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Laravel &amp;lt;= 12.x&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;VerifyCsrfToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Laravel &amp;gt;= 13.x&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutMiddleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;PreventRequestForgery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VerifyCsrfToken&lt;/code&gt; and &lt;code&gt;ValidateCsrfToken&lt;/code&gt; &lt;strong&gt;still exist as deprecated aliases&lt;/strong&gt;, so your app won't explode on day one. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Referencing them directly stops being a good idea, especially when excluding middleware in tests or route definitions.&lt;/li&gt;
&lt;li&gt;The middleware configuration API now exposes &lt;code&gt;preventRequestForgery(...)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where this really bites is test suites that call &lt;code&gt;withoutMiddleware([VerifyCsrfToken::class])&lt;/code&gt; expecting to disable the protection: because the alias points elsewhere, the exclusion may not apply the way you assumed. Grep for it before upgrading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"VerifyCsrfToken&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;ValidateCsrfToken"&lt;/span&gt; app/ tests/ routes/ bootstrap/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since you're hardening request forgery protection anyway, this is a good moment to check which security headers your domain is actually returning in production:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/security-headers/" rel="noopener noreferrer"&gt;HTTP Security Headers&lt;/a&gt; — Analyse a site's HTTP security headers (HSTS, CSP, X-Frame-Options and more) and get a grade plus the ones you are missing.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Medium impact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;serializable_classes&lt;/code&gt; in the cache config
&lt;/h3&gt;

&lt;p&gt;The default &lt;code&gt;cache&lt;/code&gt; configuration now includes &lt;code&gt;serializable_classes =&amp;gt; false&lt;/code&gt;. It's a hardening measure against &lt;strong&gt;deserialization gadget chain attacks&lt;/strong&gt;: if your &lt;code&gt;APP_KEY&lt;/code&gt; leaks, without this protection an attacker could inject serialized PHP objects into the cache and chain magic methods until they achieve code execution.&lt;/p&gt;

&lt;p&gt;The price is that &lt;strong&gt;if your application deliberately stores PHP objects in cache, it stops working&lt;/strong&gt; unless you explicitly declare which classes may be unserialized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/cache.php&lt;/span&gt;
&lt;span class="s1"&gt;'serializable_classes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;App\Data\CachedDashboardStats&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;App\Support\CachedPricingSnapshot&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you were caching arbitrary objects, you have two ways out: an explicit allow-list like the one above, or migrating to object-free payloads (plain arrays). The second is more work and the better idea.&lt;/p&gt;

&lt;p&gt;Watch out for one detail: these errors &lt;strong&gt;don't show up at deploy time&lt;/strong&gt;, they show up the first time someone reads a cache entry that contained an object. Which means in production, a while later. Flush the cache on deploy and cover it with a test.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;upsert&lt;/code&gt; with MySQL or MariaDB
&lt;/h3&gt;

&lt;p&gt;Laravel now validates that &lt;code&gt;uniqueBy&lt;/code&gt; isn't empty, and throws &lt;code&gt;InvalidArgumentException&lt;/code&gt; instead of generating invalid SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Laravel &amp;gt;= 13.x: this now blows up with InvalidArgumentException&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stats'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'views'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The curious part is that the MySQL and MariaDB drivers &lt;strong&gt;ignore the value of &lt;code&gt;uniqueBy&lt;/code&gt;&lt;/strong&gt; — they always use the table's primary and unique indexes to detect existing records. The validation applies regardless. So if your code passed an empty array because "MySQL ignores it anyway," you now have to pass the real columns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Low impact (the ones that will actually bite)
&lt;/h2&gt;

&lt;p&gt;This is where the real value is, because nobody lists these and they're the ones that surface three weeks after the deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Cache prefixes and session cookie name
&lt;/h3&gt;

&lt;p&gt;The default prefixes move from underscore to hyphen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Laravel &amp;lt;= 12.x&lt;/span&gt;
&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'_cache_'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'_database_'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'_session'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Laravel &amp;gt;= 13.x&lt;/span&gt;
&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'-cache-'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'-database-'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'-session'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only affects you if you &lt;strong&gt;don't&lt;/strong&gt; have those values defined in your configuration and rely on the framework fallback. The symptom is ugly and confusing: &lt;strong&gt;the entire cache is invalidated at once and every active session is logged out&lt;/strong&gt; the moment you deploy, because the keys and the cookie name changed.&lt;/p&gt;

&lt;p&gt;To avoid it, pin the values explicitly before upgrading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CACHE_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp_cache_
&lt;span class="nv"&gt;REDIS_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp_database_
&lt;span class="nv"&gt;SESSION_COOKIE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp_session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. &lt;code&gt;JobAttempted&lt;/code&gt; now exposes the exception
&lt;/h3&gt;

&lt;p&gt;The event swaps a boolean property for the exception object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Laravel &amp;lt;= 12.x&lt;/span&gt;
&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exceptionOccurred&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// bool&lt;/span&gt;

&lt;span class="c1"&gt;// Laravel &amp;gt;= 13.x&lt;/span&gt;
&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// Throwable|null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a change for the better — now you know &lt;em&gt;what&lt;/em&gt; failed, not just &lt;em&gt;that&lt;/em&gt; it failed — but any listener reading &lt;code&gt;exceptionOccurred&lt;/code&gt; stops working. And since it's a boolean read inside an &lt;code&gt;if&lt;/code&gt;, the failure is silent: &lt;code&gt;null&lt;/code&gt; is falsy, so your listener simply stops reacting.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. &lt;code&gt;QueueBusy&lt;/code&gt;: &lt;code&gt;$connection&lt;/code&gt; becomes &lt;code&gt;$connectionName&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A pure rename, for consistency with the rest of the queue events. If you monitor queues with your own listener, update it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"QueueBusy"&lt;/span&gt; app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8. &lt;code&gt;Container::call&lt;/code&gt; respects nullable defaults
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?Carbon&lt;/span&gt; &lt;span class="nv"&gt;$date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Laravel &amp;lt;= 12.x: a Carbon instance&lt;/span&gt;
&lt;span class="c1"&gt;// Laravel &amp;gt;= 13.x: null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It now matches the constructor injection behavior introduced in Laravel 12. If you had code relying on the container resolving the class despite the &lt;code&gt;= null&lt;/code&gt;, the result changes without warning.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Domain route precedence
&lt;/h3&gt;

&lt;p&gt;Routes with an explicit domain are now evaluated &lt;strong&gt;before&lt;/strong&gt; routes without one. This fixes the catch-all subdomain case, which previously depended on registration order.&lt;/p&gt;

&lt;p&gt;If you do multi-tenancy by subdomain, or you declare domain routes after general ones, review your matching. It's one of the few changes on this list that can alter &lt;strong&gt;which controller handles a request&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. &lt;code&gt;DELETE&lt;/code&gt; with &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt;, and &lt;code&gt;LIMIT&lt;/code&gt; on MySQL
&lt;/h3&gt;

&lt;p&gt;Previously, in a delete with a join, the &lt;code&gt;ORDER BY&lt;/code&gt; and &lt;code&gt;LIMIT&lt;/code&gt; clauses were &lt;strong&gt;silently ignored&lt;/strong&gt;. Now they're compiled and sent to the database.&lt;/p&gt;

&lt;p&gt;In practice: code you believed was bounded (&lt;code&gt;-&amp;gt;limit(100)&lt;/code&gt;) but was actually deleting without a limit can now throw &lt;code&gt;QueryException&lt;/code&gt;, because MySQL and MariaDB don't support that syntax in joined deletes.&lt;/p&gt;

&lt;p&gt;It's an awkward change but a healthy one — it was lying to you. If you run into it, inspect the generated SQL before touching anything:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/sql-formatter/" rel="noopener noreferrer"&gt;SQL Formatter / Beautifier&lt;/a&gt; — Format and beautify your SQL code with ease.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  11. Polymorphic pivot table names
&lt;/h3&gt;

&lt;p&gt;When inferring the table name for a polymorphic pivot model with a custom class, Laravel now &lt;strong&gt;pluralizes&lt;/strong&gt;. If you relied on the singular inferred name, declare the table explicitly on the pivot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Taggable&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;MorphPivot&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'taggable'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// this used to be inferred&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  12. Collection serialization restores eager-loaded relations
&lt;/h3&gt;

&lt;p&gt;When a model collection is serialized and restored — the usual case in queued jobs — relations loaded with &lt;code&gt;with()&lt;/code&gt; are now &lt;strong&gt;present again&lt;/strong&gt; after deserialization.&lt;/p&gt;

&lt;p&gt;That's usually what you want. But if your job counted on the relations &lt;em&gt;not&lt;/em&gt; being there (to force a fresh reload from the database, say), it now works with potentially stale data. That bug is especially treacherous because nothing fails: it just processes old data.&lt;/p&gt;

&lt;h3&gt;
  
  
  13. &lt;code&gt;Str&lt;/code&gt; factories reset between tests
&lt;/h3&gt;

&lt;p&gt;Laravel now clears custom &lt;code&gt;Str&lt;/code&gt; factories during test teardown. If you defined a deterministic UUID or ULID generator once — in a &lt;code&gt;setUpBeforeClass&lt;/code&gt; or a global helper — and expected it to persist across methods, you now have to define it in each test or in &lt;code&gt;setUp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Symptom: tests that pass in isolation and fail in the suite.&lt;/p&gt;

&lt;h3&gt;
  
  
  14. &lt;code&gt;extend&lt;/code&gt; callbacks are bound to the manager
&lt;/h3&gt;

&lt;p&gt;Custom driver closures registered via &lt;code&gt;extend&lt;/code&gt; now have the &lt;strong&gt;manager&lt;/strong&gt; as &lt;code&gt;$this&lt;/code&gt;, not whatever was there before. If you used &lt;code&gt;$this&lt;/code&gt; inside the closure expecting the service provider, move it into a capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: $this was the service provider&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mydriver'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// no longer works&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Now&lt;/span&gt;
&lt;span class="nv"&gt;$provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mydriver'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  15. Bootstrap 3 pagination view names
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Laravel &amp;lt;= 12.x
pagination::default
pagination::simple-default

// Laravel &amp;gt;= 13.x
pagination::bootstrap-3
pagination::simple-bootstrap-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only relevant if you reference those view names directly. The old names were confusing — &lt;code&gt;default&lt;/code&gt; wasn't the actual default — and now they're explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  16. &lt;code&gt;Js::from&lt;/code&gt; stops escaping Unicode
&lt;/h3&gt;

&lt;p&gt;It now uses &lt;code&gt;JSON_UNESCAPED_UNICODE&lt;/code&gt; by default. If you have tests or output comparisons expecting &lt;code&gt;è&lt;/code&gt; instead of &lt;code&gt;è&lt;/code&gt;, update the expectations. Any accented character triggers this.&lt;/p&gt;

&lt;h3&gt;
  
  
  17. The PHP 8.5 polyfill and global helpers
&lt;/h3&gt;

&lt;p&gt;This is the easiest one to miss. Laravel 13 adds a dependency on &lt;strong&gt;&lt;code&gt;symfony/polyfill-php85&lt;/code&gt;&lt;/strong&gt;, which on PHP below 8.5 defines global functions such as &lt;code&gt;array_first()&lt;/code&gt; and &lt;code&gt;array_last()&lt;/code&gt; if they don't already exist.&lt;/p&gt;

&lt;p&gt;The conflict: the historical &lt;code&gt;array_first()&lt;/code&gt; from &lt;code&gt;laravel/helpers&lt;/code&gt; &lt;strong&gt;accepted a callback&lt;/strong&gt; to return the first element matching a condition. The polyfilled one only returns the array's first element. Same apparent signature, different behavior.&lt;/p&gt;

&lt;p&gt;If you still carry &lt;code&gt;laravel/helpers&lt;/code&gt; or your own global helpers with those names, always use &lt;code&gt;Arr&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Arr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Arr&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Very low impact: only if you maintain packages
&lt;/h2&gt;

&lt;p&gt;If you implement framework contracts yourself, there are new methods to add. If you only consume Laravel, skip this section entirely.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contract&lt;/th&gt;
&lt;th&gt;Method to add&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Contracts\Cache\Store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;touch($key, $seconds)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Contracts\Bus\Dispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dispatchAfterResponse($command, $handler = null)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Contracts\Routing\ResponseFactory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eventStream(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Contracts\Auth\MustVerifyEmail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;markEmailAsUnverified()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Illuminate\Contracts\Queue\Queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pendingSize&lt;/code&gt;, &lt;code&gt;delayedSize&lt;/code&gt;, &lt;code&gt;reservedSize&lt;/code&gt;, &lt;code&gt;creationTimeOfOldestPendingJob&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of that, &lt;strong&gt;instantiating a model while that model is still booting now throws &lt;code&gt;LogicException&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;static&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// LogicException on Laravel 13&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the HTTP client's &lt;code&gt;throw&lt;/code&gt; and &lt;code&gt;throwIf&lt;/code&gt; signatures now declare their callbacks explicitly, which only matters if you override those methods in a custom response class.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does NOT break, whatever you may have read
&lt;/h2&gt;

&lt;p&gt;A couple of false claims are circulating about this upgrade:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;app/Http/Kernel.php&lt;/code&gt; does not disappear in Laravel 13.&lt;/strong&gt; That file was removed in &lt;strong&gt;Laravel 11&lt;/strong&gt;, with the new application structure. If you're coming from 12 you no longer have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel 13 does not require PHP 8.4.&lt;/strong&gt; The minimum is 8.3 and the supported range goes up to 8.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VerifyCsrfToken&lt;/code&gt; is not removed.&lt;/strong&gt; It remains a deprecated alias. Migrating it is wise, but it won't take your deploy down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There are no "zero breaking changes."&lt;/strong&gt; The documentation itself says &lt;em&gt;minimal&lt;/em&gt;, and flags two high-impact changes. The gap between "minimal" and "zero" is exactly this article.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The upgrade, in practice
&lt;/h2&gt;

&lt;p&gt;With the full list in front of you, the procedure is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. New branch, always&lt;/span&gt;
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; upgrade/laravel-13

&lt;span class="c"&gt;# 2. PHP 8.3+ before anything else&lt;/span&gt;
php &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# 3. Pin cache and session prefixes so you don't invalidate everything&lt;/span&gt;
&lt;span class="c"&gt;#    (CACHE_PREFIX, REDIS_PREFIX, SESSION_COOKIE in your .env)&lt;/span&gt;

&lt;span class="c"&gt;# 4. Grep for what you know changes&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"VerifyCsrfToken&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;ValidateCsrfToken"&lt;/span&gt; app/ tests/ routes/ bootstrap/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"exceptionOccurred&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;QueueBusy"&lt;/span&gt; app/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"array_first&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;array_last"&lt;/span&gt; app/

&lt;span class="c"&gt;# 5. Update dependencies&lt;/span&gt;
composer update &lt;span class="nt"&gt;--with-all-dependencies&lt;/span&gt;

&lt;span class="c"&gt;# 6. Clear everything&lt;/span&gt;
php artisan optimize:clear

&lt;span class="c"&gt;# 7. Tests&lt;/span&gt;
php artisan &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel also offers an assisted route: if you have &lt;strong&gt;Laravel Boost &lt;code&gt;^2.0&lt;/code&gt;&lt;/strong&gt; installed, the &lt;code&gt;/upgrade-laravel-v13&lt;/code&gt; command walks the upgrade from Claude Code, Cursor, OpenCode, Gemini, or VS Code. And for large projects, &lt;a href="https://laravelshift.com" rel="noopener noreferrer"&gt;Shift&lt;/a&gt; is still the paid option that automates the bulk of it.&lt;/p&gt;

&lt;p&gt;One last step almost nobody takes, and it saves surprises: diff your configuration files against &lt;code&gt;laravel/laravel&lt;/code&gt; on the 13.x branch. Many changes on this list — &lt;code&gt;serializable_classes&lt;/code&gt; being the obvious one — live in &lt;code&gt;config/&lt;/code&gt;, not in the framework, and don't arrive on their own with &lt;code&gt;composer update&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One catch when you copy, though: the 13 skeleton's &lt;code&gt;config/session.php&lt;/code&gt; ships &lt;code&gt;'serialization' =&amp;gt; 'json'&lt;/code&gt;. If your app was on &lt;code&gt;php&lt;/code&gt; and you sync that value as-is, &lt;strong&gt;every active session is invalidated&lt;/strong&gt; on deploy and every user has to log in again. For a seamless upgrade, keep it at &lt;code&gt;php&lt;/code&gt;. Moving to &lt;code&gt;json&lt;/code&gt; is the recommended hardening against deserialization attacks, but only if you don't store PHP objects in the session, and only when you're fine forcing that re-login.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/diff-checker/" rel="noopener noreferrer"&gt;Text Diff Checker&lt;/a&gt; — Compare two texts and highlight the differences line by line, with unified and side-by-side views and ignore case or whitespace. All in your browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Checklist before you merge
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] PHP 8.3 or higher on local, CI, and production&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;laravel/framework ^13.0&lt;/code&gt;, &lt;code&gt;laravel/tinker ^3.0&lt;/code&gt;, &lt;code&gt;phpunit ^12.0&lt;/code&gt; or &lt;code&gt;pest ^4.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Zero references to &lt;code&gt;VerifyCsrfToken&lt;/code&gt; in tests and routes&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;CACHE_PREFIX&lt;/code&gt;, &lt;code&gt;REDIS_PREFIX&lt;/code&gt;, and &lt;code&gt;SESSION_COOKIE&lt;/code&gt; pinned explicitly&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;config/cache.php&lt;/code&gt; reviewed if you cache PHP objects&lt;/li&gt;
&lt;li&gt;[ ] Every &lt;code&gt;upsert()&lt;/code&gt; call passes a non-empty &lt;code&gt;uniqueBy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;JobAttempted&lt;/code&gt; and &lt;code&gt;QueueBusy&lt;/code&gt; listeners updated&lt;/li&gt;
&lt;li&gt;[ ] Domain routes reviewed if you use subdomains&lt;/li&gt;
&lt;li&gt;[ ] Test suite green &lt;strong&gt;as a whole&lt;/strong&gt;, not just file by file&lt;/li&gt;
&lt;li&gt;[ ] Cache flushed on deploy&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Laravel 13 is an honest upgrade: 10 minutes for most apps, and the new capabilities — the first-party AI SDK, JSON:API resources, &lt;code&gt;Queue::route()&lt;/code&gt;, PHP attributes for middleware and jobs, &lt;code&gt;Cache::touch()&lt;/code&gt;, vector search with &lt;code&gt;whereVectorSimilarTo()&lt;/code&gt; — arrive without demanding a rewrite in exchange.&lt;/p&gt;

&lt;p&gt;But "minimal" is not "zero," and the changes that actually hurt aren't the two high-impact ones — those are documented and surface immediately — but the low-impact ones that fail silently: the cache that invalidates wholesale, the listener that stops reacting, the job that processes stale data, the test that only fails in the suite.&lt;/p&gt;

&lt;p&gt;That date has passed: &lt;strong&gt;August 13, 2026&lt;/strong&gt;. Since then, every bug you find in Laravel 12 is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  When does Laravel 12 support end?
&lt;/h3&gt;

&lt;p&gt;Laravel 12 stopped receiving bug fixes on August 13, 2026 and stops receiving security patches on February 24, 2027. Laravel applies the same policy to every major version: 18 months of bug fixes and 2 years of security. Laravel 12 is not an LTS release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which PHP version does Laravel 13 require?
&lt;/h3&gt;

&lt;p&gt;Laravel 13 requires PHP 8.3 as a minimum and supports up to PHP 8.5. It is not true that it requires PHP 8.4: it works correctly on 8.3. If you are coming from Laravel 12 on PHP 8.2, raising the PHP version is the only real infrastructure work in the upgrade.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the high-impact breaking changes in Laravel 13?
&lt;/h3&gt;

&lt;p&gt;There are two. The first is updating dependencies in composer.json: laravel/framework to ^13.0, laravel/tinker to ^3.0, laravel/boost to ^2.0, phpunit/phpunit to ^12.0, and pestphp/pest to ^4.0. The second is that the CSRF middleware VerifyCsrfToken is renamed to PreventRequestForgery and adds origin verification through the Sec-Fetch-Site header. VerifyCsrfToken still exists as a deprecated alias.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is app/Http/Kernel.php removed in Laravel 13?
&lt;/h3&gt;

&lt;p&gt;No. That file was removed in Laravel 11 with the new application structure, not in Laravel 13. If you are coming from Laravel 12 your project no longer has it and there is nothing to do about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my entire cache disappear after upgrading to Laravel 13?
&lt;/h3&gt;

&lt;p&gt;Because the default cache, Redis, and session prefixes changed from underscore to hyphen: from myapp_cache_ to myapp-cache-. It only affects applications that rely on the framework fallback instead of defining those values in their configuration. The effect is that every cache key changes and active sessions are logged out. To avoid it, pin CACHE_PREFIX, REDIS_PREFIX, and SESSION_COOKIE in your .env before upgrading.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does upgrading from Laravel 12 to Laravel 13 take?
&lt;/h3&gt;

&lt;p&gt;The official documentation estimates 10 minutes for a typical application. In practice, the time goes into raising PHP to 8.3 if you were on 8.2, reviewing third-party packages that do not yet declare Laravel 13 compatibility, and checking the low-impact changes that fail silently. With Laravel Boost 2.0 installed you can use the /upgrade-laravel-v13 command to have an AI assistant guide the process.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/what-breaks-upgrading-to-laravel-13/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>migration</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The AI model your business runs on is being retired: the 2026 shutdown calendar</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:20:03 +0000</pubDate>
      <link>https://dev.to/marco_odev/the-ai-model-your-business-runs-on-is-being-retired-the-2026-shutdown-calendar-58h8</link>
      <guid>https://dev.to/marco_odev/the-ai-model-your-business-runs-on-is-being-retired-the-2026-shutdown-calendar-58h8</guid>
      <description>&lt;p&gt;&lt;strong&gt;OpenAI already shut down the Assistants API on 26 August 2026. The next cutoffs are 24 September, when it retires the Videos API and &lt;code&gt;sora-2&lt;/code&gt;; 28 September, for the last legacy completion snapshots; and 23 October, for &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; and &lt;code&gt;gpt-4&lt;/code&gt;.&lt;/strong&gt; If your business runs a chatbot, an email classifier or any AI automation that was built for you in 2023 or 2024, there is a very high chance it is wired to one of those last two models. And on shutdown day it will not degrade gracefully: it will return an error and stop working.&lt;/p&gt;

&lt;p&gt;This is not speculation or industry chatter. These are dates published by the providers themselves on their own deprecation pages, with six months' notice. The problem is that the notice arrives by email to the developer account that created the API key — which half the time belongs to an agency that no longer works with you.&lt;/p&gt;

&lt;p&gt;This article is the full calendar, how to find out in three minutes whether it affects you, and what to do if it does. If what you need is what each model costs, that lives in the &lt;a href="https://ortamarco.me/en/blog/how-much-openai-claude-gemini-api-costs-2026/" rel="noopener noreferrer"&gt;guide to OpenAI, Claude and Gemini API pricing&lt;/a&gt;; here we go straight to what shuts down and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  What nobody told you when you bought it
&lt;/h2&gt;

&lt;p&gt;AI models are not software you buy once. They are a service with an expiry date, and the provider decides the date.&lt;/p&gt;

&lt;p&gt;OpenAI puts it plainly in its documentation: &lt;em&gt;"software relying on OpenAI models may need occasional updates to keep working."&lt;/em&gt; Anthropic writes almost the same sentence. Translated into the language of the person paying the invoice: &lt;strong&gt;the chatbot you were handed as "finished" carries mandatory maintenance, and nobody put it in the quote.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This changes a decision plenty of businesses got wrong. When you asked for a quote to automate something with AI, you compared the implementation price and the monthly token cost. What you did not compare — because nobody showed you — was &lt;strong&gt;how often you would be paying for a migration&lt;/strong&gt;. And on that, as we will see, providers are nothing alike.&lt;/p&gt;




&lt;h2&gt;
  
  
  First: does this affect you? The three-minute check
&lt;/h2&gt;

&lt;p&gt;Before reading the calendar, find out which model you actually use. Not what you think you use: what is written in the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you have access to the OpenAI dashboard
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Sign in to &lt;a href="https://platform.openai.com" rel="noopener noreferrer"&gt;platform.openai.com&lt;/a&gt; with the account that pays for the API.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Usage&lt;/strong&gt; and switch the breakdown to group &lt;strong&gt;by model&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look at the last 30 days. Those names are what you are really running.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  If you have access to the Anthropic console
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://platform.claude.com/usage" rel="noopener noreferrer"&gt;Usage page in the Claude Console&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Export&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The CSV gives you usage broken down by API key and by model. Anthropic documents this exact procedure for auditing deprecated models.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  If you have access to nothing
&lt;/h3&gt;

&lt;p&gt;This is the most common case, and it is the finding in itself. If nobody at your company can log into the provider's dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The API key belongs to a third party.&lt;/strong&gt; The retirement notice goes to them, not to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot audit the spend&lt;/strong&gt;, so you also do not know whether you are overpaying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot migrate without them&lt;/strong&gt;, which means the continuity of a business process depends on that person picking up the phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are governance problems, not technical ones, and they are fixed the same way: the AI provider account is opened in the company's name, paid by the company, and the external developer is added as an invited user. If this is your situation, that is the first job — before any migration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/llm-token-counter/" rel="noopener noreferrer"&gt;LLM Token Counter &amp;amp; AI Cost Calculator&lt;/a&gt; — Count the tokens in your text or prompt and estimate the cost across GPT-5, Claude, Gemini, Kimi K3, Grok and DeepSeek. Exact OpenAI counting, per-call and monthly costs, with prompt caching.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  OpenAI's shutdown calendar
&lt;/h2&gt;

&lt;p&gt;These are the dates published on &lt;a href="https://developers.openai.com/api/docs/deprecations" rel="noopener noreferrer"&gt;OpenAI's official deprecations page&lt;/a&gt;, checked on 16 August 2026 and reconfirmed on 13 September 2026. I have bolded the ones that hit small businesses hardest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shutdown&lt;/th&gt;
&lt;th&gt;What dies&lt;/th&gt;
&lt;th&gt;Recommended replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;26 Aug 2026&lt;/strong&gt; &lt;em&gt;(already happened)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Assistants API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Responses API + Conversations API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 Sep 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sora-2&lt;/code&gt;, &lt;code&gt;sora-2-pro&lt;/code&gt;, Videos API&lt;/td&gt;
&lt;td&gt;&lt;em&gt;no replacement&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28 Sep 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-3.5-turbo-instruct&lt;/code&gt;, &lt;code&gt;babbage-002&lt;/code&gt;, &lt;code&gt;davinci-002&lt;/code&gt;, &lt;code&gt;gpt-3.5-turbo-1106&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gpt-5.6-terra&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Oct 2026&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gpt-5.4-cyber&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gpt-5.6-cyber&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;23 Oct 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;gpt-3.5-turbo-0125&lt;/code&gt;, &lt;code&gt;gpt-4-0613&lt;/code&gt;, &lt;code&gt;gpt-4-turbo&lt;/code&gt;, &lt;code&gt;gpt-4-1106-preview&lt;/code&gt;, &lt;code&gt;gpt-4o-2024-05-13&lt;/code&gt;, &lt;code&gt;o1&lt;/code&gt;, &lt;code&gt;o1-pro&lt;/code&gt;, &lt;code&gt;o3-mini&lt;/code&gt;, &lt;code&gt;o4-mini&lt;/code&gt;, &lt;code&gt;gpt-4.1-nano&lt;/code&gt;, &lt;code&gt;gpt-image-1&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.6-sol&lt;/code&gt; / &lt;code&gt;gpt-5.6-terra&lt;/code&gt; / &lt;code&gt;gpt-5.6-luna&lt;/code&gt; (&lt;code&gt;gpt-image-1&lt;/code&gt; → &lt;code&gt;gpt-image-2&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31 Oct 2026&lt;/td&gt;
&lt;td&gt;Evals: existing evals become &lt;strong&gt;read-only&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Promptfoo (OpenAI's migration path)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;30 Nov 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;v1/prompts&lt;/code&gt; API and reusable prompts, Agent Builder, Evals dashboard and API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Prompts in your own code · Agents SDK or ChatGPT Workspace Agents · Promptfoo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Dec 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-image-1-mini&lt;/code&gt;, &lt;code&gt;gpt-image-1.5&lt;/code&gt;, &lt;code&gt;chatgpt-image-latest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gpt-image-2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11 Dec 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5-2025-08-07&lt;/code&gt;, &lt;code&gt;gpt-5-mini-2025-08-07&lt;/code&gt;, &lt;code&gt;gpt-5-nano-2025-08-07&lt;/code&gt;, &lt;code&gt;gpt-5-pro-2025-10-06&lt;/code&gt;, &lt;code&gt;o3-2025-04-16&lt;/code&gt;, &lt;code&gt;o3-pro-2025-06-10&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.6-sol&lt;/code&gt; (&lt;code&gt;reasoning.mode: pro&lt;/code&gt; for the pro ones) / &lt;code&gt;gpt-5.6-terra&lt;/code&gt; (mini) / &lt;code&gt;gpt-5.6-luna&lt;/code&gt; (nano)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 Jan 2027&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-realtime&lt;/code&gt;, &lt;code&gt;gpt-audio&lt;/code&gt;, &lt;code&gt;gpt-4o-audio&lt;/code&gt;, &lt;code&gt;gpt-4o-realtime&lt;/code&gt; and mini variants&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-realtime-2.1&lt;/code&gt; (&lt;code&gt;gpt-realtime-2.1-mini&lt;/code&gt; for the realtime minis) / &lt;code&gt;gpt-audio-1.5&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 Feb 2027&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;whisper-1&lt;/code&gt;, &lt;code&gt;gpt-4o-transcribe&lt;/code&gt;, &lt;code&gt;gpt-4o-mini-transcribe&lt;/code&gt;, &lt;code&gt;gpt-4o-transcribe-diarize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-transcribe&lt;/code&gt; / &lt;code&gt;gpt-live-transcribe&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those dates are closer than the headline one, and easy to miss because they read like small print next to it. &lt;strong&gt;24 September&lt;/strong&gt; retires the Videos API along with &lt;code&gt;sora-2&lt;/code&gt; and &lt;code&gt;sora-2-pro&lt;/code&gt; — with no replacement listed, so anything built on video generation needs a different plan, not just a different model string. &lt;strong&gt;28 September&lt;/strong&gt; retires the last of the old completion-style snapshots (&lt;code&gt;gpt-3.5-turbo-instruct&lt;/code&gt;, &lt;code&gt;babbage-002&lt;/code&gt;, &lt;code&gt;davinci-002&lt;/code&gt;, &lt;code&gt;gpt-3.5-turbo-1106&lt;/code&gt;) into &lt;code&gt;gpt-5.6-terra&lt;/code&gt;. Neither is wired into as many small-business chatbots as &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; or &lt;code&gt;gpt-4&lt;/code&gt; are — but if your stack touches either one, your date is 24 or 28 September, not 23 October.&lt;/p&gt;

&lt;p&gt;And a third one shows that six months' notice does not apply to everything: &lt;code&gt;gpt-5.4-cyber&lt;/code&gt; was announced on 11 September and shuts down on 1 October, &lt;strong&gt;twenty days later&lt;/strong&gt;. The six-month minimum OpenAI promises covers generally available models, not its whole catalogue, so do not assume it for variants like this one.&lt;/p&gt;

&lt;p&gt;Three readings of that table matter more than the table itself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. 23 October is the big one.&lt;/strong&gt; &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; and &lt;code&gt;gpt-4-0613&lt;/code&gt; are the models that appear literally in every tutorial from 2023 and 2024. Any integration copied from a blog, a course or the documentation of that era has them hardcoded. And because they worked without complaining for three years, nobody has touched them since. (For what actually changed in the replacements, see the &lt;a href="https://ortamarco.me/en/blog/new-chatgpt-models-gpt-5-6-2026/" rel="noopener noreferrer"&gt;GPT-5.6 launch rundown&lt;/a&gt;.) ⚠️ And if the &lt;strong&gt;GPT-6 Astra&lt;/strong&gt; launch on 3 September tempts you to migrate straight there: for a chatbot or a classifier that is an expensive mistake. Astra costs 20x the input and 33x the output of &lt;code&gt;gpt-3.5-turbo&lt;/code&gt;, and its million-token window hides a 2x surcharge past 272K. The migration ladder by workload type is in &lt;a href="https://ortamarco.me/en/blog/gpt-6-astra-api-developers/" rel="noopener noreferrer"&gt;GPT-6 Astra for developers&lt;/a&gt;. And if you're the one migrating the code: changing the name isn't enough, because LangChain and LlamaIndex still default to &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; and the substitutes reject tools on Chat Completions unless you turn reasoning off. The details, with audit greps, are in &lt;a href="https://ortamarco.me/en/blog/what-breaks-openai-gpt-4-shutdown-october-23/" rel="noopener noreferrer"&gt;what breaks in your code on October 23&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Whole APIs are shutting down, not just models.&lt;/strong&gt; The Assistants API, Agent Builder, the Evals platform and reusable prompts all disappear. That is not fixed by changing a string: it is rewriting the integration. Anyone who built on Assistants is looking at days of work, not minutes.&lt;/p&gt;

&lt;p&gt;Reusable prompts are the sneakiest case, because the prompt text is not in your repository: it is stored in the OpenAI dashboard, and your code only references it with &lt;code&gt;prompt: { id: "pmpt_…" }&lt;/code&gt; in the Responses API call. On 30 November the &lt;code&gt;v1/prompts&lt;/code&gt; API and those prompt objects shut down, so the &lt;a href="https://developers.openai.com/api/docs/guides/prompting/migrate-from-prompt-object" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt; asks you to take the content out of the dashboard, move it into your code and send it as &lt;code&gt;input&lt;/code&gt;. Evals also has an intermediate step: &lt;strong&gt;from 31 October your existing evals become read-only&lt;/strong&gt;, a month before the dashboard and API disappear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pinned snapshots die even when the model "lives on".&lt;/strong&gt; Look at &lt;code&gt;gpt-5-2025-08-07&lt;/code&gt;. GPT-5 is not disappearing as a family, but &lt;strong&gt;that specific snapshot is&lt;/strong&gt;. If your code pins the date — which is the good practice for reproducible results — then you are on the shutdown calendar even while running a model that feels current.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anthropic's calendar works differently
&lt;/h2&gt;

&lt;p&gt;Anthropic publishes the same thing on its &lt;a href="https://platform.claude.com/docs/en/about-claude/model-deprecations" rel="noopener noreferrer"&gt;model deprecations page&lt;/a&gt;, but with one difference that changes how you plan.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Retirement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-opus-4-1-20250805&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Retired&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 Aug 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude-opus-4-20250514&lt;/code&gt;, &lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Retired&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15 Jun 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude-3-7-sonnet-20250219&lt;/code&gt;, &lt;code&gt;claude-3-5-haiku-20241022&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Retired&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;19 Feb 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-3-haiku-20240307&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Retired&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20 Apr 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-5-20250929&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;td&gt;not sooner than 29 Sep 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;td&gt;not sooner than 15 Oct 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-opus-4-5-20251101&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;td&gt;not sooner than 24 Nov 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude-opus-5&lt;/code&gt;, &lt;code&gt;claude-sonnet-5&lt;/code&gt;, &lt;code&gt;claude-fable-5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;td&gt;2027&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Careful not to read those "not sooner than" entries as shutdown dates: they are floors, not announcements. As of 13 September 2026 none of the three Claude 4.5 models has an announced deprecation, and with the 60 days' notice Anthropic commits to, none of them can be retired before 12 November.&lt;/p&gt;

&lt;p&gt;The difference is in the notice period, and it is a big one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Committed advance notice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6 months&lt;/strong&gt; for generally available models · 3 months for specialised variants · &lt;strong&gt;2 weeks&lt;/strong&gt; for preview models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Anthropic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;60 days&lt;/strong&gt; for publicly released models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google (Gemini)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Its deprecations page sets no notice period: it tracks &lt;em&gt;"the announced earliest shutdown dates"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sixty days is not much. &lt;code&gt;claude-opus-4-1&lt;/code&gt; was announced on 5 June and switched off on 5 August: &lt;strong&gt;two months exactly&lt;/strong&gt;. If your business slows down in August, or your developer is on holiday, or nobody simply reads that email, the entire notice window passes you by.&lt;/p&gt;

&lt;p&gt;This does not make Anthropic a bad choice — its models are excellent, and the retirement pace is the flip side of a very high release pace. It means &lt;strong&gt;the notice period is a product feature you should compare before choosing&lt;/strong&gt;, the same as price or latency. And it appears in none of the comparison posts doing the rounds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gemini: two shutdowns between September and October
&lt;/h2&gt;

&lt;p&gt;Google publishes its own on the &lt;a href="https://ai.google.dev/gemini-api/docs/deprecations" rel="noopener noreferrer"&gt;Gemini API deprecations page&lt;/a&gt;, checked on 13 September 2026. Between September and December 2026 there are two dates, plus one that has already passed and is worth checking if your integration dates from 2025:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shutdown&lt;/th&gt;
&lt;th&gt;What dies&lt;/th&gt;
&lt;th&gt;Recommended replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 Jun 2026 &lt;em&gt;(already happened)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemini-2.0-flash&lt;/code&gt;, &lt;code&gt;gemini-2.0-flash-001&lt;/code&gt;, &lt;code&gt;gemini-2.0-flash-lite&lt;/code&gt;, &lt;code&gt;gemini-2.0-flash-lite-001&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemini-3.6-flash&lt;/code&gt; / &lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Sep 2026&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-omni-flash-preview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-omni-1.1-flash&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Oct 2026&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-flash-image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-3.1-flash-image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details in that table. &lt;code&gt;gemini-omni-flash-preview&lt;/code&gt;, the video generation model, launched on 30 June and shuts down three months later: the textbook example of why you do not build a business process on a &lt;em&gt;preview&lt;/em&gt;. And for &lt;code&gt;gemini-2.5-flash-image&lt;/code&gt; — the original Nano Banana — the official page lists &lt;code&gt;gemini-3.1-flash-image-preview&lt;/code&gt; as the replacement, but that preview was already shut down on 25 June 2026 and the same page gives the stable version, &lt;code&gt;gemini-3.1-flash-image&lt;/code&gt; (Nano Banana 2), as its replacement, which is where it makes sense to migrate directly.&lt;/p&gt;

&lt;p&gt;Gemini 2.5's text models (&lt;code&gt;gemini-2.5-pro&lt;/code&gt;, &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;) still have no announced shutdown date.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trap that catches the most people: where you run it changes the date
&lt;/h2&gt;

&lt;p&gt;This is the detail nobody has written up properly, and the one that produces the most expensive surprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dates above are the original provider's. If you consume the same model through somebody else's cloud, the dates are different.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic says so explicitly: its dates apply to the Claude API, the Claude Platform on AWS and Microsoft Foundry. &lt;strong&gt;Amazon Bedrock and Google Cloud Vertex AI set their own retirement schedules&lt;/strong&gt;, so the same model's status and dates can differ.&lt;/li&gt;
&lt;li&gt;The same is true of OpenAI: &lt;strong&gt;Azure OpenAI publishes its own retirements table&lt;/strong&gt;, which is not always in sync with OpenAI direct.
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    A["Same model,&amp;lt;br/&amp;gt;three different dates"] --&amp;gt; B["Provider's direct API"]
    A --&amp;gt; C["Azure OpenAI / Microsoft Foundry"]
    A --&amp;gt; D["Amazon Bedrock / Google Vertex"]
    B --&amp;gt; B1["Date on the official&amp;lt;br/&amp;gt;deprecations page"]
    C --&amp;gt; C1["Microsoft's own schedule"]
    D --&amp;gt; D1["Cloud operator's&amp;lt;br/&amp;gt;own schedule"]
    B1 --&amp;gt; E["⚠️ Check the table for&amp;lt;br/&amp;gt;WHERE YOU RUN IT,&amp;lt;br/&amp;gt;not the model maker's"]
    C1 --&amp;gt; E
    D1 --&amp;gt; E&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The practical consequence: if your integration goes through Bedrock or Azure, &lt;strong&gt;the table you need is not the one I just published&lt;/strong&gt;. It is your cloud's. Setting the alarm for the wrong date is as bad as not setting one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What else breaks, and it isn't a model
&lt;/h2&gt;

&lt;p&gt;Beyond the shutdowns, there are silent retirements that break code without any model dying.&lt;/p&gt;

&lt;p&gt;The live example right now: &lt;strong&gt;Anthropic deprecated the &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt; and &lt;code&gt;top_k&lt;/code&gt; parameters&lt;/strong&gt; as of Claude Opus 4.7. It does not ignore them — it &lt;strong&gt;returns a 400 error&lt;/strong&gt; if you send them with anything other than the default value. Any integration that tuned the model's "creativity" with &lt;code&gt;temperature: 0.7&lt;/code&gt; — which is practically every integration from 2023–2024 — stops working the moment you move to a current model.&lt;/p&gt;

&lt;p&gt;This matters because it dismantles the naive plan. Plenty of people assume migrating means swapping &lt;code&gt;"gpt-4"&lt;/code&gt; for &lt;code&gt;"gpt-5.6-sol"&lt;/code&gt; and done. Sometimes it is. But you may also be facing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parameters that are no longer accepted (the case above).&lt;/li&gt;
&lt;li&gt;Different response formats, if you used structured output.&lt;/li&gt;
&lt;li&gt;Entire APIs rewritten (Assistants → Responses).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts that stop performing the same.&lt;/strong&gt; A new model is not the old one but better: it is a different one. Prompts tuned by trial and error against &lt;code&gt;gpt-4&lt;/code&gt; can do worse on &lt;code&gt;gpt-5.6-sol&lt;/code&gt; until they are readjusted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point eats the most budget and is the one that never gets quoted.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration procedure
&lt;/h2&gt;

&lt;p&gt;If you have a technical team, this is the order that works. If you do not, skip to the next section.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Inventory before touching anything
&lt;/h3&gt;

&lt;p&gt;Search the whole codebase for model strings, not just the obvious file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# OpenAI models hardcoded in the code&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"gpt-3.5-turbo&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;gpt-4-0613&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;gpt-4-turbo&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-2024"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.js"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.ts"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Prompts stored in the OpenAI dashboard (v1/prompts shuts down 30 Nov 2026)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"pmpt_&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;prompt_id"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Anthropic models&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"claude-3&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-2025&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-4-2025"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# And what nearly everyone forgets: environment variables and config&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"MODEL"&lt;/span&gt; .env .env.example config/ 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check the places where the model name is not in the repository at all: n8n or Make panels, serverless functions, cron jobs, and any third-party automation consuming your key.&lt;/p&gt;

&lt;p&gt;This kind of break stays invisible until the exact day for a specific reason: a hardcoded model ID needs no validation to keep working. It succeeds every single time you call it, for as long as the model is alive, then fails on the very next call after retirement. There is no warning header on the &lt;code&gt;200&lt;/code&gt; responses the week before, no gradual dip in quality to notice — the API goes straight from working to a &lt;code&gt;400&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; the moment the shutdown date arrives. If nothing in your code specifically catches that error, the person using your product just sees something break, with nothing in the interface pointing at the real cause.&lt;/p&gt;

&lt;p&gt;That is also why it is worth pulling your API logs now, not only grepping your code. A spike in &lt;code&gt;400&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; responses from &lt;code&gt;api.openai.com&lt;/code&gt; or &lt;code&gt;api.anthropic.com&lt;/code&gt; around a retirement date is the model dying in production — and it is usually a customer who reports it first, not a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Pin the version, don't use the alias
&lt;/h3&gt;

&lt;p&gt;If your code says plain &lt;code&gt;"gpt-4"&lt;/code&gt;, you are using an alias the provider repoints whenever it likes. That looks convenient and is exactly the opposite: it means &lt;strong&gt;your system's behaviour can change on a Tuesday without you touching anything&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The correct practice is to pin the dated snapshot and control yourself when you move up. Yes, that puts you on the shutdown calendar — but it puts you there predictably, which is precisely what you want.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Compare side by side before switching
&lt;/h3&gt;

&lt;p&gt;Do not swap the model in production and wait to see what happens. Take 30 or 50 real cases from your history, run them through the old model and the new one, and compare outputs side by side. It is an afternoon's work and it is the difference between a migration and an incident.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/diff-checker/" rel="noopener noreferrer"&gt;Text Diff Checker&lt;/a&gt; — Compare two texts and highlight the differences line by line, with unified and side-by-side views and ignore case or whitespace. All in your browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Put the provider behind your own layer
&lt;/h3&gt;

&lt;p&gt;This is the work that makes the next shutdown boring. Instead of calling the provider's SDK from twenty places, call it from one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A single place where the decision of which model to use lives.&lt;/span&gt;
&lt;span class="c1"&gt;// The rest of the application asks for "reasoning" or "classification",&lt;/span&gt;
&lt;span class="c1"&gt;// not for "gpt-5.6-sol".&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-5.6-sol&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-5.6-terra&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;summarisation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;MODELS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MODELS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;callOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;callAnthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, the next shutdown is editing one line of an object and re-running step 3's comparison. Without it, it is find-and-replace across twenty files and hoping.&lt;/p&gt;

&lt;p&gt;You do not need a framework for this. LangChain or LlamaIndex solve the same problem and bring far more baggage than a small business needs; thirty lines of your own usually do the job without adding a dependency that can also be deprecated.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you have no technical team: what to demand, and from whom
&lt;/h2&gt;

&lt;p&gt;For the owner or operations lead who will not be touching code, the job is different — and more important.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to ask, in writing, from whoever maintains your integration:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which exact model each process uses&lt;/strong&gt;, with the full name and snapshot date. "We use ChatGPT" is not an answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which platform it runs on&lt;/strong&gt; — direct API, Azure, Bedrock, Vertex — because the real date depends on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shutdown date for each one&lt;/strong&gt;, taken from that platform's table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who receives the deprecation emails.&lt;/strong&gt; If it is an account you do not control, change that this week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the migration costs and who pays for it.&lt;/strong&gt; This is the uncomfortable question, and it needs asking now, not in October.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;And what belongs in the contract from here on:&lt;/strong&gt; deprecation maintenance is not an enhancement or a nice-to-have — it is what keeps the system switched on. Either it is covered by a retainer, or it is priced up front. Discovering it on shutdown day, with the process already down, is the worst negotiating position available.&lt;/p&gt;

&lt;p&gt;I say this from the uncomfortable side of the table: I make a living building these integrations. And even so, a client who does not understand that this carries maintenance is a client who will feel ripped off a year from now — rightly.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the switch costs (and why you sometimes come out ahead)
&lt;/h2&gt;

&lt;p&gt;Migrating is not always a net expense. Newer models tend to be considerably cheaper per token than the ones they replace, and considerably more capable, so three effects offset each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In your favour:&lt;/strong&gt; the replacement's price per million tokens is usually lower. A process that cost X on &lt;code&gt;gpt-4&lt;/code&gt; can cost a fraction on its successor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In your favour:&lt;/strong&gt; more capable models need shorter prompts for the same quality, and a shorter prompt is fewer tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Against you:&lt;/strong&gt; reasoning models burn internal tokens that you also pay for. Switching to one of those by accident can multiply the bill even as the unit price drops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only honest way to know is to measure it with your real data before switching. The numbers and the method are in the &lt;a href="https://ortamarco.me/en/blog/how-much-openai-claude-gemini-api-costs-2026/" rel="noopener noreferrer"&gt;OpenAI, Claude and Gemini API pricing guide&lt;/a&gt;, and if you want to estimate a specific prompt's consumption before sending it, the token counter alongside does it with no signup.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/tokenizer-visualizer/" rel="noopener noreferrer"&gt;Tokenizer Visualizer&lt;/a&gt; — Paste text and see exactly which tokens a language model splits it into, with their IDs. Check why Spanish costs more tokens than English.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The radical way out: make it un-switchoffable
&lt;/h2&gt;

&lt;p&gt;There is one way to make this article never apply to you: &lt;strong&gt;run the model yourself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An open model running on your own hardware cannot be retired by anyone. It keeps working exactly the same three years from now, with the same outputs, no deprecation emails and no date on the calendar. For stable internal processes — classifying email, extracting data from documents, summarising — it is a real option, and today it runs on affordable hardware.&lt;/p&gt;

&lt;p&gt;It is not free: you pay in hardware, in electricity, and in quality that trails the frontier models. I wrote the full analysis, with the machine I use and measured numbers, in &lt;a href="https://ortamarco.me/en/blog/local-llm-business-privacy/" rel="noopener noreferrer"&gt;AI without sending data outside: a local LLM for small firms&lt;/a&gt;. The continuity section of that article is exactly this problem seen from the other side.&lt;/p&gt;

&lt;p&gt;For most people the sensible call is not picking a side but splitting: provider models for what needs frontier quality, your own model for the stable processes you would rather not tie to anybody's calendar.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What exactly happens on shutdown day?
&lt;/h3&gt;

&lt;p&gt;The request fails. There is no degradation and no automatic substitution: the model stops being reachable and the API returns an error. Anthropic says it literally — &lt;em&gt;"requests to retired models will fail"&lt;/em&gt; — and OpenAI that the model or endpoint &lt;em&gt;"will no longer be accessible."&lt;/em&gt; If your application does not handle that error, what the end user sees is a broken screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  I use ChatGPT in the browser, not the API. Does this affect me?
&lt;/h3&gt;

&lt;p&gt;Far less. The dates in this article are for the &lt;strong&gt;API&lt;/strong&gt;, which is what integrations consume. In the ChatGPT app, retired models are swapped for the current one automatically and at most you notice it answers differently. The serious risk is to anything automated.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if my chatbot uses the Assistants API?
&lt;/h3&gt;

&lt;p&gt;Ask whoever built it, and if you cannot, check the OpenAI dashboard: if assistants appear under &lt;strong&gt;Assistants&lt;/strong&gt;, it is in use. The code gives it away too — it will call endpoints under &lt;code&gt;/v1/assistants&lt;/code&gt; or &lt;code&gt;/v1/threads&lt;/code&gt;. If you have not migrated yet, that one is no longer a countdown: the Assistants API already shut down on 26 August 2026, so a chatbot still calling it is already broken, not about to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is migrating just changing the model name?
&lt;/h3&gt;

&lt;p&gt;Sometimes yes, sometimes no. Swapping the string is enough if you only used plain text generation. But you may need to rewrite if you used the Assistants API, if you send already-deprecated parameters — &lt;code&gt;temperature&lt;/code&gt; returns a 400 error on current Claude models — or if your prompts were tuned against the old model. Budget for the output comparison, which is the part that actually takes time.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often will this happen again?
&lt;/h3&gt;

&lt;p&gt;Permanently. OpenAI gives at least 6 months' notice for generally available models; Anthropic, 60 days. At that pace, the realistic plan is &lt;strong&gt;a model review every six months&lt;/strong&gt; and a small migration once a year. It is recurring maintenance, like upgrading the framework or renewing an SSL certificate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does using LangChain or a chatbot SaaS protect me?
&lt;/h3&gt;

&lt;p&gt;Partly, with caveats. An abstraction layer makes the switch cheaper, but it does not stop the underlying model dying or remove the need to revalidate quality. And if the middleman is a SaaS, you inherit its calendar &lt;strong&gt;and&lt;/strong&gt; the risk that it deprecates features of its own — which is exactly what OpenAI did with Agent Builder, its own product, retiring on 30 November 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion: put it in the calendar today
&lt;/h2&gt;

&lt;p&gt;Boiled down to what to do this week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find out which model you actually use.&lt;/strong&gt; OpenAI dashboard grouped by model, or the CSV export from the Claude console. Three minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check which platform it runs on&lt;/strong&gt;, because that is what determines the real date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set an alarm for every date that applies to you:&lt;/strong&gt; 24 September 2026 if you use Sora or the Videos API; 28 September if you still call old completion-style models; 30 September or 2 October if you generate video or images with Gemini (&lt;code&gt;gemini-omni-flash-preview&lt;/code&gt;, &lt;code&gt;gemini-2.5-flash-image&lt;/code&gt;); 23 October if any &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; or &lt;code&gt;gpt-4&lt;/code&gt; shows up in your inventory; 31 October and 30 November if you use Evals, Agent Builder or prompts stored in the dashboard; 1 December if you generate images with &lt;code&gt;gpt-image-1.5&lt;/code&gt; or &lt;code&gt;gpt-image-1-mini&lt;/code&gt;; and 11 December if you pin &lt;code&gt;gpt-5&lt;/code&gt; or &lt;code&gt;o3&lt;/code&gt; snapshots. (If you were still calling the Assistants API, that alarm already went off on 26 August — check today whether it is broken.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your code calls the Responses API with &lt;code&gt;prompt: { id: "pmpt_…" }&lt;/code&gt;, copy that prompt into your repository.&lt;/strong&gt; The text lives in the OpenAI dashboard, not in your code, and on 30 November 2026 the &lt;code&gt;v1/prompts&lt;/code&gt; API and reusable prompts shut down. The official guide says to move the content into your code and send it as &lt;code&gt;input&lt;/code&gt;. If the account belongs to your agency, ask them for it now, not on 30 November.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify who receives the deprecation emails.&lt;/strong&gt; If it is not somebody at your company, fix that before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And ask what the migration costs&lt;/strong&gt;, while there is still room to negotiate it calmly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Model shutdowns are not an exotic technical problem: they are ordinary maintenance for a technology moving fast. What is a problem is finding out about them on the day the chatbot stops answering.&lt;/p&gt;

&lt;p&gt;If you have an AI integration in production and cannot answer question 1 on that list, &lt;a href="https://ortamarco.me/en/#contacto" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and we will review it. Auditing which models you run and when they die is a short job, and it is better done weeks before the shutdown than the day after.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/ai-model-retirements-2026/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>openai</category>
      <category>claude</category>
      <category>migration</category>
    </item>
    <item>
      <title>Node 26: What Breaks When You Upgrade, and When the Jump Is Worth It (LTS Guide)</title>
      <dc:creator>Marco Orta</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:19:46 +0000</pubDate>
      <link>https://dev.to/marco_odev/node-26-what-breaks-when-you-upgrade-and-when-the-jump-is-worth-it-lts-guide-4go2</link>
      <guid>https://dev.to/marco_odev/node-26-what-breaks-when-you-upgrade-and-when-the-jump-is-worth-it-lts-guide-4go2</guid>
      <description>&lt;p&gt;&lt;strong&gt;Node 26 shipped on May 5, 2026 and enters LTS on October 28. It's a calm upgrade — the list of things that actually break is short, and almost all of it has been warned about for years — but it has one property no previous version had: it's the last release of the two-per-year model.&lt;/strong&gt; Starting with Node 27, the project moves to &lt;strong&gt;one release per year, and every release is LTS&lt;/strong&gt;. The odd/even distinction that spent a decade deciding which version you could run in production disappears.&lt;/p&gt;

&lt;p&gt;That makes this upgrade more than a version bump: it's the last train on the old schedule, and it's worth boarding knowing what's inside. Here's what really breaks, sorted by impact, and at the end the full calendar so you can decide when to move.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: where are you coming from?
&lt;/h2&gt;

&lt;p&gt;The effort depends on your starting point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;From Node 24 (the current LTS):&lt;/strong&gt; the comfortable scenario. The incompatible changes are few and very localized; for most projects the migration is checklist work, not refactoring work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From Node 22:&lt;/strong&gt; still reasonable, but with more fronts — you absorb everything that changed in 24 (V8, permissions, minimum glibc) plus what's new in 26. Node 22 leaves maintenance in &lt;strong&gt;April 2027&lt;/strong&gt;, so you have a real deadline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From Node 25:&lt;/strong&gt; odd releases were never for production, and this was the last one that will ever exist. Jump to 26 without thinking twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The schedule change: Node 27 debuts a new model
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule" rel="noopener noreferrer"&gt;official project announcement&lt;/a&gt; sums it up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Through Node 26&lt;/th&gt;
&lt;th&gt;From Node 27 on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Major releases per year&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LTS versions&lt;/td&gt;
&lt;td&gt;Even-numbered only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;All of them&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short-lived odd versions&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpha channel&lt;/td&gt;
&lt;td&gt;Didn't exist&lt;/td&gt;
&lt;td&gt;6 months, allows semver-major changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total support per version&lt;/td&gt;
&lt;td&gt;36 months&lt;/td&gt;
&lt;td&gt;36 months (unchanged)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The concrete dates that affect you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 26&lt;/strong&gt;: Active LTS from &lt;strong&gt;October 28, 2026&lt;/strong&gt;, maintenance from October 20, 2027, end of life on &lt;strong&gt;April 30, 2029&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 27&lt;/strong&gt;: alpha channel opens &lt;strong&gt;October 28, 2026&lt;/strong&gt; (the same day 26 goes LTS), release on &lt;strong&gt;April 22, 2027&lt;/strong&gt;, and it will also be LTS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 24&lt;/strong&gt;: Active LTS until &lt;strong&gt;October 20, 2026&lt;/strong&gt;, when it moves to maintenance (critical and security fixes only); end of life on &lt;strong&gt;April 30, 2028&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact dates come from the &lt;a href="https://github.com/nodejs/Release/blob/main/schedule.json" rel="noopener noreferrer"&gt;&lt;code&gt;schedule.json&lt;/code&gt; in Node's release repository&lt;/a&gt;, the file the project itself works from; the new schedule was announced on &lt;a href="https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule" rel="noopener noreferrer"&gt;the Node.js blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The practical consequence: &lt;strong&gt;you'll no longer have to memorize which versions "count"&lt;/strong&gt;. Every release from here on is production-grade, and one release per year means fewer migrations per decade for your team. If you maintain libraries, the part that concerns you is the alpha channel: six months to test breaking changes before each release ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  High impact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The internal &lt;code&gt;_stream_*&lt;/code&gt; modules are gone
&lt;/h3&gt;

&lt;p&gt;The removal with the most shrapnel, because it doesn't break &lt;em&gt;your&lt;/em&gt; code: it breaks old dependencies. The internal modules &lt;code&gt;_stream_readable&lt;/code&gt;, &lt;code&gt;_stream_writable&lt;/code&gt;, &lt;code&gt;_stream_duplex&lt;/code&gt;, &lt;code&gt;_stream_transform&lt;/code&gt;, &lt;code&gt;_stream_passthrough&lt;/code&gt; and &lt;code&gt;_stream_wrap&lt;/code&gt; — deprecated since Node 12 — have been removed entirely. Any &lt;code&gt;require('_stream_readable')&lt;/code&gt; now throws &lt;code&gt;MODULE_NOT_FOUND&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No package maintained in recent years does that, but ecosystems drag fossils along. To find out whether you have one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"_stream_"&lt;/span&gt; node_modules &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.js"&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something shows up, you have two exits: update that package to a version using the public API (&lt;code&gt;node:stream&lt;/code&gt;), or — if it's abandoned — replace it. The &lt;a href="https://www.npmjs.com/package/readable-stream" rel="noopener noreferrer"&gt;&lt;code&gt;readable-stream&lt;/code&gt;&lt;/a&gt; polyfill covers most cases where the package is yours and you just need a mechanical migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Undici 8: the global &lt;code&gt;fetch&lt;/code&gt; gets strict
&lt;/h3&gt;

&lt;p&gt;Node 26 ships &lt;strong&gt;Undici 8.0&lt;/strong&gt;, the implementation behind the global &lt;code&gt;fetch&lt;/code&gt;. It brings stricter header validation and changes to redirect handling. If your code builds requests with dynamically generated headers — from a database, from user input, from another API — what used to pass silently can now throw a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pattern that shows up most in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If any value carries \n or \r (e.g. a token copied with a trailing newline),&lt;/span&gt;
&lt;span class="c1"&gt;// Undici 8 rejects it instead of silently sanitizing.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The right fix is not catching the error: it's sanitizing the value at the edge, where it enters your system (&lt;code&gt;token.trim()&lt;/code&gt; when you read it). Having the request blow up before it leaves is the good behavior — with Undici 7 that token with a &lt;code&gt;\n&lt;/code&gt; would travel and fail at the server on the other side, which is far harder to debug.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Native addons: mandatory recompilation
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;NODE_MODULE_VERSION&lt;/code&gt; moves to &lt;strong&gt;147&lt;/strong&gt;, so every compiled addon (bcrypt, sharp, better-sqlite3, canvas…) needs to be rebuilt or ship prebuilds for the new ABI. The error is unmistakable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: The module was compiled against a different Node.js version using NODE_MODULE_VERSION 137.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm rebuild&lt;/code&gt; solves it locally; in CI and Docker, rebuilding the image on the new base is enough. The detail that can actually stall you: the build requirements went up — &lt;strong&gt;GCC 13.2 minimum&lt;/strong&gt;, and &lt;strong&gt;Python 3.9 support was dropped&lt;/strong&gt; from the build toolchain. If you compile addons inside an old Debian/Alpine image, update the image before fighting the wrong error.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free tool:&lt;/strong&gt; &lt;a href="https://ortamarco.me/en/tools/docker-compose-validator/" rel="noopener noreferrer"&gt;Docker Compose Validator&lt;/a&gt; — Validate your compose.yaml against the official Compose schema and catch key typos, wrong types and YAML errors, with the exact line. All in your browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Medium impact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;module.register()&lt;/code&gt; is runtime-deprecated
&lt;/h3&gt;

&lt;p&gt;The async module-hooks API — the one used by instrumentation loaders, on-the-fly transpilation and APM agents — now emits a runtime deprecation. The designated replacement is the synchronous &lt;strong&gt;&lt;code&gt;module.registerHooks()&lt;/code&gt;&lt;/strong&gt; API.&lt;/p&gt;

&lt;p&gt;This reaches you mostly through third parties: observability agents (Datadog, New Relic, OpenTelemetry) and tools like &lt;code&gt;tsx&lt;/code&gt; live in that layer. The action item isn't rewriting anything of yours: it's &lt;strong&gt;updating those dependencies to the versions that already migrated&lt;/strong&gt; before moving production to 26.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;writeHeader()&lt;/code&gt; removed from the HTTP server
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;http.Server.prototype.writeHeader()&lt;/code&gt; was an undocumented alias of &lt;code&gt;writeHead()&lt;/code&gt;. It's gone. If it appears in your code, the fix is literal, letter for letter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Now&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;grep -rn "writeHeader" src/&lt;/code&gt; tells you in seconds whether you're affected. Frameworks (Express, Fastify, Nest) have always used the documented API; this bites hand-written HTTP code from many years ago.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. &lt;code&gt;type: "module"&lt;/code&gt; packages: the extensionless-CJS exception is gone
&lt;/h3&gt;

&lt;p&gt;In packages declared as ESM, loading a CommonJS file &lt;strong&gt;without an extension&lt;/strong&gt; relied on a historical resolver exception that Node 26 removes. If a &lt;code&gt;"type": "module"&lt;/code&gt; package of yours had a &lt;code&gt;require&lt;/code&gt; of a path without an explicit &lt;code&gt;.cjs&lt;/code&gt;/&lt;code&gt;.js&lt;/code&gt;, it now fails. The fix is adding the extension — which is what ESM resolution always asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Low impact
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt; with no backing file returns &lt;code&gt;undefined&lt;/code&gt; instead of throwing.&lt;/strong&gt; If you probed availability with try/catch around &lt;code&gt;localStorage&lt;/code&gt;, invert the check: it's a plain &lt;code&gt;if&lt;/code&gt; now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;QuotaExceededError&lt;/code&gt; becomes a &lt;code&gt;DOMException&lt;/code&gt;-derived interface.&lt;/strong&gt; Only affects code comparing the error by constructor or numeric &lt;code&gt;code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Several crypto APIs advance in the deprecation cycle&lt;/strong&gt; — one reaches end of life and others start warning at runtime. If you see no warnings with &lt;code&gt;--pending-deprecation&lt;/code&gt; today, you're not affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;assert&lt;/code&gt; accepts printf-style messages&lt;/strong&gt; in assertion errors. It's an addition, but it changes text output: if your CI parses assert messages (bad idea, while you're here), review it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does NOT break, even if you've read otherwise
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Temporal API doesn't break &lt;code&gt;Date&lt;/code&gt;.&lt;/strong&gt; Temporal arrives &lt;strong&gt;enabled by default&lt;/strong&gt; in Node 26, but it's an addition: all your &lt;code&gt;Date&lt;/code&gt; code keeps working exactly as (badly as) before. Migrating is optional and gradual — I have a &lt;a href="https://ortamarco.me/en/blog/temporal-api-in-node-26/" rel="noopener noreferrer"&gt;full guide to Temporal in Node 26&lt;/a&gt; with the conversion table from date-fns and dayjs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running TypeScript directly is now stable.&lt;/strong&gt; &lt;code&gt;node app.ts&lt;/code&gt; works with no flags: type stripping left experimental status. The only thing that "breaks" is the &lt;code&gt;--experimental-transform-types&lt;/code&gt; flag, which was removed precisely because the feature graduated — if you had it in an npm script, delete it and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;V8 14.6 only adds.&lt;/strong&gt; &lt;code&gt;Map.prototype.getOrInsert()&lt;/code&gt;, &lt;code&gt;Iterator.concat()&lt;/code&gt; and the rest of the Chromium 146 features are additions with zero migration cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The upgrade, in practice
&lt;/h2&gt;

&lt;p&gt;The order that produces the fewest surprises:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install 26 next to your current version&lt;/strong&gt;, not over it. With &lt;a href="https://ortamarco.me/en/blog/nvm-vs-fnm-vs-volta-node-version-managers/" rel="noopener noreferrer"&gt;fnm or Volta&lt;/a&gt; it's one command, and rolling back takes seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm rebuild&lt;/code&gt;&lt;/strong&gt; (or delete &lt;code&gt;node_modules&lt;/code&gt; and reinstall) to regenerate native addons against ABI 147.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the suite with deprecations visible:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   node &lt;span class="nt"&gt;--pending-deprecation&lt;/span&gt; &lt;span class="nt"&gt;--trace-deprecation&lt;/span&gt; ./node_modules/.bin/vitest run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;module.register()&lt;/code&gt; and crypto ones show up here, with a stack trace to the culprit.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;grep&lt;/code&gt; for the fossils:&lt;/strong&gt; &lt;code&gt;_stream_&lt;/code&gt;, &lt;code&gt;writeHeader(&lt;/code&gt;. Two minutes that save you a production surprise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update your agents&lt;/strong&gt; (APM, telemetry, loaders) to versions that declare Node 26 support — it's the layer that takes longest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the &lt;code&gt;engines&lt;/code&gt; field&lt;/strong&gt; in your &lt;code&gt;package.json&lt;/code&gt; and your critical dependencies: &lt;code&gt;npm ls --depth=0&lt;/code&gt; plus a glance at &lt;code&gt;EBADENGINE&lt;/code&gt; warnings during install.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When should you upgrade?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;New project:&lt;/strong&gt; start on 26 today. There's no reason to begin something that will live for years on 24.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production on 24:&lt;/strong&gt; the sensible window opens on &lt;strong&gt;October 28, 2026&lt;/strong&gt;, when 26 enters LTS and eight days after 24 moves to maintenance — with 24 supported until April 2028 there's no rush, but no reason to wait until the end either: the longer you wait, the further this list drifts from fresh memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production on 22:&lt;/strong&gt; move now. April 2027 looks far away until it doesn't, and the 22 → 26 jump is bigger than 24 → 26.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/temporal-api-in-node-26/" rel="noopener noreferrer"&gt;The Temporal API in Node 26&lt;/a&gt; — the big feature of this release, with migration from date-fns and dayjs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/nvm-vs-fnm-vs-volta-node-version-managers/" rel="noopener noreferrer"&gt;nvm vs fnm vs Volta&lt;/a&gt; — the comfortable way to keep 24 and 26 side by side while you migrate.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ortamarco.me/en/blog/complete-docker-guide-2026/" rel="noopener noreferrer"&gt;The Complete Docker Guide for 2026&lt;/a&gt; — test the upgrade in a container before touching anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  When does Node 26 enter LTS?
&lt;/h3&gt;

&lt;p&gt;On October 28, 2026, according to schedule.json in the nodejs/Release repository. Node 26 shipped as the Current release on May 5, 2026, moves to maintenance on October 20, 2027, and its end of life is scheduled for April 30, 2029. It has no announced codename yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changes in the Node.js release schedule starting with Node 27?
&lt;/h3&gt;

&lt;p&gt;Starting with Node 27 (April 2027) the project moves from two major releases per year to one, and every release will be LTS: the odd/even distinction disappears. A six-month alpha channel is added (the Node 27 alpha opens on October 28, 2026) where breaking changes are tested before release. Total support per version remains 36 months.&lt;/p&gt;

&lt;h3&gt;
  
  
  What breaks when upgrading from Node 24 to Node 26?
&lt;/h3&gt;

&lt;p&gt;Little, and very localized: the removed internal &lt;em&gt;stream&lt;/em&gt;* modules (affects very old dependencies), the removed writeHeader() HTTP server alias, Undici 8 with stricter header validation in fetch, the runtime deprecation of module.register(), and native addon recompilation due to the ABI change to NODE_MODULE_VERSION 147. For most projects it is checklist work, not refactoring work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run TypeScript directly in Node 26?
&lt;/h3&gt;

&lt;p&gt;Yes. Type stripping is stable in Node 26: node app.ts works with no flags. The --experimental-transform-types flag was removed precisely because the feature is no longer experimental; if you had it in a script, just delete it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long is Node 24 supported?
&lt;/h3&gt;

&lt;p&gt;Node 24 moves from Active LTS to maintenance on October 20, 2026 (critical and security fixes only from then on) and its end of life is scheduled for April 30, 2028, so there is no immediate urgency to leave it. The recommended migration window to 26 opens on October 28, 2026, when 26 enters LTS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the Temporal API in Node 26 break code that uses Date?
&lt;/h3&gt;

&lt;p&gt;No. Temporal ships enabled by default but it is a new API that coexists with Date: it does not change or remove any existing behavior. Migrating to Temporal is optional and can be done gradually, file by file.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/" rel="noopener noreferrer"&gt;ortamarco.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>backend</category>
      <category>migration</category>
    </item>
  </channel>
</rss>
