<?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: Qasim Parray</title>
    <description>The latest articles on DEV Community by Qasim Parray (@abyzgenic).</description>
    <link>https://dev.to/abyzgenic</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%2F4120845%2Fdf746bdb-4b77-4db2-8b04-ed1d2da71842.png</url>
      <title>DEV Community: Qasim Parray</title>
      <link>https://dev.to/abyzgenic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abyzgenic"/>
    <language>en</language>
    <item>
      <title>How to Handle Scope Creep: The Quick One That Cost Me a Week</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sat, 12 Sep 2026 02:39:56 +0000</pubDate>
      <link>https://dev.to/abyzgenic/how-to-handle-scope-creep-the-quick-one-that-cost-me-a-week-1jml</link>
      <guid>https://dev.to/abyzgenic/how-to-handle-scope-creep-the-quick-one-that-cost-me-a-week-1jml</guid>
      <description>&lt;p&gt;Confession: the worst scope creep I ever dealt with was my own fault, and it started with the words "sure, that's a quick one."&lt;/p&gt;

&lt;p&gt;It was a Laravel admin panel for a logistics company. Fixed price, six weeks, clear spec. Around week three the client asked if the export button could also email the CSV. Quick one. Then could the email go to a list of addresses. Quick one. Then could the list be managed in the panel. Then could those users get their own login with read-only access. By week eight I had built a small user management system, a notification layer, and a permissions model, none of which were in the contract, and I was the one who'd said yes every time.&lt;/p&gt;

&lt;p&gt;I've been freelancing and running small projects for long enough now that I've stopped thinking of scope creep as something clients do to you. It's something that happens in the gap between what was written down and what everyone assumed. This post is about how I handle that gap now: what I put in contracts, what I say in the moment, and the one habit that fixed most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope creep is mostly a definition problem
&lt;/h2&gt;

&lt;p&gt;The standard definition, the one you'll find in the &lt;a href="https://www.atlassian.com/work-management/project-management/scope-creep" rel="noopener noreferrer"&gt;Atlassian guide to scope creep&lt;/a&gt; and every project management textbook, is uncontrolled growth in a project's requirements after the project has started. That's accurate and almost useless, because it makes it sound like a thing with a clear start line.&lt;/p&gt;

&lt;p&gt;In practice the growth is never uncontrolled from the client's point of view. Each request is small, reasonable, and obviously related to the thing you're already building. The export button emailing the CSV is a two-hour job. The client isn't trying to get free work. They're seeing the software for the first time and having ideas, which is what you'd want from an engaged client.&lt;/p&gt;

&lt;p&gt;The problem is that "related to the thing you're building" and "in the thing you agreed to build" are different sets, and if the contract doesn't draw the line, the line gets drawn by whoever is more uncomfortable saying no. That's usually the developer.&lt;/p&gt;

&lt;p&gt;So the first fix isn't a script for pushing back. It's writing the scope down in a way that makes the line visible to both of you before anyone has to be the bad guy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes in my scope document now
&lt;/h2&gt;

&lt;p&gt;Every fixed-price project I take gets a scope section that has two lists, and the second one matters more than the first.&lt;/p&gt;

&lt;p&gt;The first list is what's included. Screens, features, integrations, whatever the unit of work is for that project. Pretty standard.&lt;/p&gt;

&lt;p&gt;The second list is what's explicitly not included, and it's specific. Not "additional features are out of scope", which is legally fine and practically meaningless. It says things like: user roles beyond admin and viewer, email notifications of any kind, data import from the old system, mobile layouts for the admin panel. I write this list by imagining the project is done and asking what the client will ask for in the first week. I'm usually right about half of it.&lt;/p&gt;

&lt;p&gt;The point of the second list isn't to refuse those things. It's to make the conversation, when it comes, sound like "that's on the not-included list, so let's price it" instead of "well, I didn't think that was included." The first sentence is a fact both parties signed. The second is an argument.&lt;/p&gt;

&lt;p&gt;I also add one sentence that has done more work for me than any other: changes to scope are welcome and will be quoted separately before work begins. Welcome is the important word. It tells the client that asking is fine, and that asking has a process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute rule
&lt;/h2&gt;

&lt;p&gt;Here's the habit that fixed most of it. When a request comes in mid-project, I do not answer yes or no in the same message. I answer with an estimate.&lt;/p&gt;

&lt;p&gt;"Emailing the CSV: that's about two hours, so roughly 250. Want me to add it to the change list?"&lt;/p&gt;

&lt;p&gt;This does several things at once. It makes the cost visible, which is the entire problem with small requests: the client can't see the cost, so to them it's free. It gives them a real decision to make rather than a favour to accept. And it keeps my tone neutral, because I'm not saying no, I'm saying here's the price.&lt;/p&gt;

&lt;p&gt;About a third of requests disappear at this point. The client sees 250 and decides emailing the CSV can wait. Another third get a yes and go on the change list. The remaining third turn into a conversation, which is where the actual project management happens.&lt;/p&gt;

&lt;p&gt;What I don't do anymore is absorb the two hours to be nice. I did that on the logistics project maybe fifteen times, and fifteen two-hour favours is nearly a week of unpaid work. The client didn't know they'd received a week of free work, because I never told them. That's not generosity. That's just bad bookkeeping with extra resentment.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the request is actually a spec bug
&lt;/h2&gt;

&lt;p&gt;Sometimes the request isn't creep at all. Sometimes it's the client noticing that the spec was wrong, and if you'd noticed it during scoping you'd have included it.&lt;/p&gt;

&lt;p&gt;The logistics panel had a reporting screen. The spec said "monthly report of deliveries by region". When I built it, the client said, reasonably, that they also needed it by driver, because that's how they actually ran their operation. Nothing in the spec said driver. But the report as specified was useless to them, and a useless report was never what either of us intended.&lt;/p&gt;

&lt;p&gt;I treat these differently. If the spec was ambiguous and the client's reading is reasonable, I eat it. If the spec was clear and the client wants something different, it's a change. The test I use is: would a competent third party reading the spec have built what I built? If yes, the client is asking for a change. If they'd have asked a clarifying question I failed to ask, that's on me.&lt;/p&gt;

&lt;p&gt;I still tell the client which bucket it landed in, either way. "This one's on me, the spec should have caught it" costs nothing and buys a lot of goodwill for the next time I quote a change. This is also where I've learned to be honest about my own mistakes rather than quietly fixing them; I wrote about a much more expensive version of that lesson in &lt;a href="https://abrarqasim.com/blog/when-not-to-use-ai-automation-the-refund-bot-that-cost-me-a-client" rel="noopener noreferrer"&gt;the post about the refund bot that cost me a client&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retainers make this easier, projects make it harder
&lt;/h2&gt;

&lt;p&gt;One structural note. Almost all of my scope creep pain has come from fixed-price projects. On retainers, where the client buys a block of hours per month, scope creep mostly stops being a concept. A new request is just a new item in the queue, and the question is what it displaces, not whether it's free.&lt;/p&gt;

&lt;p&gt;That doesn't mean retainers are always right. Clients with a defined one-off build want a fixed price, and I'd want one too in their position. But when a project turns out to be the first of many, I now suggest moving to a retainer early, sometimes before the first project is finished. The client gets a predictable bill, I get a predictable month, and the "is this in scope" conversation is replaced with "where does this sit in the priority list", which is a much better conversation to be having.&lt;/p&gt;

&lt;p&gt;If a client resists a retainer but keeps asking for changes, that's information. The &lt;a href="https://asana.com/resources/what-is-scope-creep" rel="noopener noreferrer"&gt;Asana article on scope creep&lt;/a&gt; lists unclear priorities as a leading cause, and in my experience the client who wants both a fixed price and an open feature list hasn't decided what the project is yet. Better to find that out in week two than week eight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change this week
&lt;/h2&gt;

&lt;p&gt;If you've got a project in flight right now, do one thing: open a shared document called "Change list", put a table in it with columns for request, estimate, status, and date, and send the link to your client with a one-line note that says you'll log any new requests there so nothing gets lost. Then, the next time a request arrives, put it in the table with an estimate before you do anything else.&lt;/p&gt;

&lt;p&gt;That table has turned more awkward conversations into boring ones than any contract clause I've written. The client can see what they've asked for, what it costs, and what they've approved. You can see how much unpaid work you were about to do. Nobody has to be the bad guy, because the table is the bad guy, and it's very polite about it.&lt;/p&gt;

&lt;p&gt;If you're setting up your first few contracts and want to see how I structure the scope section on real projects, the &lt;a href="https://abrarqasim.com/work" rel="noopener noreferrer"&gt;work page on my site&lt;/a&gt; has a few case studies where I've been fairly open about what was in and out of scope, and what it cost when I got that wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/how-to-handle-scope-creep-the-quick-one-that-cost-me-a-week/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelancing</category>
      <category>scopecreep</category>
      <category>clientmanagement</category>
      <category>contracts</category>
    </item>
    <item>
      <title>ARC-AGI-3 and GPT-6 Astra: Two Scores, One Model, and the Harness Gap</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 11 Sep 2026 22:39:54 +0000</pubDate>
      <link>https://dev.to/abyzgenic/arc-agi-3-and-gpt-6-astra-two-scores-one-model-and-the-harness-gap-2gg5</link>
      <guid>https://dev.to/abyzgenic/arc-agi-3-and-gpt-6-astra-two-scores-one-model-and-the-harness-gap-2gg5</guid>
      <description>&lt;p&gt;Short version for the impatient: GPT-6 Astra scored 62.7% on ARC-AGI-3 and also 99.9% on ARC-AGI-3, on the same day, on the same test set, and both numbers are real. The difference is the harness. If you only read one headline this week, make it that one, because it's the most useful thing anyone has said about benchmarks in a long time.&lt;/p&gt;

&lt;p&gt;I spent Thursday evening reading the &lt;a href="https://arcprize.org/blog/astra" rel="noopener noreferrer"&gt;ARC Prize write-up on Astra&lt;/a&gt; instead of the thing I was supposed to be doing, which was fixing a flaky test in a client's checkout flow. The test is still flaky. But I came away with a clearer picture of why the agent evals I run for my own projects keep giving me numbers I don't trust, and it's the same reason Astra has two scores.&lt;/p&gt;

&lt;p&gt;This isn't a "is it AGI" post. ARC Prize themselves say it isn't, and I have no interest in relitigating that. It's a post about what the 37-point gap between those two scores means for anyone who ships agents, which at this point is most of us.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ARC-AGI-3 actually tests
&lt;/h2&gt;

&lt;p&gt;Quick background, because ARC-AGI-3 is only six months old and the earlier versions were different animals. ARC-AGI-1 and 2 were grid puzzles: here's a few input-output pairs, infer the rule, apply it. Static. One shot.&lt;/p&gt;

&lt;p&gt;ARC-AGI-3 launched in March 2026 as an interactive benchmark. Each task is a small turn-based game with no instructions, no stated goal, and no rules explained. The agent has to poke at the environment, work out what the mechanics are, figure out what "winning" even means, and then do it, across levels that get harder. ARC Prize built hundreds of these and calibrated difficulty with about 500 members of the public, who solved all of them. At launch, every frontier model scored under 1%.&lt;/p&gt;

&lt;p&gt;The thing I find interesting about the design is that it scores action efficiency on top of completion. Finishing a level in 400 moves when the median human took 40 counts against you. That's a much closer match to how I'd judge an agent doing real work. An agent that eventually fixes the bug after 300 tool calls is not the same as one that fixes it in twelve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two harnesses, in plain terms
&lt;/h2&gt;

&lt;p&gt;Here's the part that matters. ARC Prize ran Astra two ways.&lt;/p&gt;

&lt;p&gt;The Standard harness is their provider-neutral interface. Every model gets the same thing: the game state, the action space, and the ability to carry forward whatever notes it chooses to write for itself between turns. What it keeps is up to the model. What it doesn't write down, it loses.&lt;/p&gt;

&lt;p&gt;The Provider Adapter harness lets the model use the context-management features its provider built for it. For Astra, that means the opaque reasoning state (the stuff OpenAI doesn't show you) persists between requests, and long conversations get compacted rather than truncated. The model gets to reuse its own prior thinking instead of reconstructing it from notes.&lt;/p&gt;

&lt;p&gt;Standard: 62.7% at max reasoning effort, for about $26,000. Provider Adapter: 99.9% at high effort, for about $19,000. Faster too. ARC Prize reports the adapter runs were roughly 3.66 times quicker by elapsed time and used 49% fewer tokens across the 167 game-and-reasoning-level pairs both harnesses solved.&lt;/p&gt;

&lt;p&gt;Read that again. Cheaper, faster, and 37 points better. Same model weights. The only thing that changed is whether the model was allowed to remember how it had been thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gap is the story, not the 99.9
&lt;/h2&gt;

&lt;p&gt;I've seen a few takes this week along the lines of "the 99.9 is fake, the real score is 62.7". I don't think that's right, and ARC Prize don't frame it that way either. They're going to report both on the leaderboard, labelled, going forward. The Standard number answers "how do models compare on an identical minimal interface". The adapter number answers "how well does this model do when it's used the way its provider intends".&lt;/p&gt;

&lt;p&gt;Both are legitimate questions. But if you're building something, the second one is the one you're living with. Nobody ships an agent on a deliberately handicapped interface to keep the comparison fair.&lt;/p&gt;

&lt;p&gt;What the gap tells you is how much of an agent's performance lives outside the weights. In Astra's case, more than a third of the score. That lines up uncomfortably well with something I noticed on my own projects and wrote up in &lt;a href="https://abrarqasim.com/blog/ai-agent-memory-is-a-dose-not-a-switch" rel="noopener noreferrer"&gt;my post on agent memory being a dose, not a switch&lt;/a&gt;: the same model, given a slightly different memory policy, behaved like a different product. I'd assumed that was me being sloppy with prompts. Turns out the best-resourced eval team in the field sees the same thing at a scale of 37 points.&lt;/p&gt;

&lt;p&gt;The Standard-harness results across reasoning levels are also worth a look. Astra at "none" (no reasoning) scored 35.2%. At "low" it scored 17.5%. Lower. Some reasoning did worse than no reasoning on this task under the Standard harness, and I don't have a tidy explanation for that. ARC Prize don't offer one either. Under the adapter harness, every level from none to max lands between 96.7% and 99.9%. The harness raised the ceiling and flattened the whole curve at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Astra was actually doing in there
&lt;/h2&gt;

&lt;p&gt;The bit of the write-up I keep coming back to isn't the numbers. It's the replays.&lt;/p&gt;

&lt;p&gt;Under the Standard harness, Astra invented its own shorthand for tracking game state. Not English notes. Things like &lt;code&gt;L8: hub q2 (8↓). Lengths: 14=1, 9=1&lt;/code&gt; to record a level, a rotation index, and mechanism lengths, and &lt;code&gt;extend8 to3; retract10 to2; shorten8 to1&lt;/code&gt; as an ordered plan. ARC Prize call it an on-the-fly algebraic notation. It's a model compressing its own world model into the smallest thing that survives the note-passing boundary, because the harness forces it to.&lt;/p&gt;

&lt;p&gt;That's the same thing a person does when the whiteboard is too small. It's also, I think, why the Standard score is as high as it is. The model built a workaround for the missing memory. The adapter harness just removed the need for the workaround.&lt;/p&gt;

&lt;p&gt;In a third setup, the PRO-LONG harness where the model gets a code sandbox, Astra wrote itself small libraries per game: &lt;code&gt;maze_solver.py&lt;/code&gt;, &lt;code&gt;patrol_solver.py&lt;/code&gt;, a &lt;code&gt;sync_state.py&lt;/code&gt; to check predictions against what it observed. ARC Prize are careful to say that's model-plus-tools and not comparable to the human baseline. Fair. But it's also exactly what you'd want an agent to do when handed a codebase it's never seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that should worry you about your own evals
&lt;/h2&gt;

&lt;p&gt;Here's my uncomfortable conclusion. If a 37-point swing can come from the harness alone on a benchmark this carefully built, then the numbers coming out of my own agent evals, run on a harness I threw together in an afternoon, are measuring my harness at least as much as they're measuring the model.&lt;/p&gt;

&lt;p&gt;I made this point from a different angle in &lt;a href="https://abrarqasim.com/blog/llm-evaluation-tools-keep-missing-the-long-game" rel="noopener noreferrer"&gt;the post about LLM evaluation tools missing the long game&lt;/a&gt;. Most eval tooling scores single turns. Agents don't live in single turns. The thing that made Astra go from 62.7 to 99.9 is a multi-turn concern: what persists, what gets compacted, what the model is allowed to reuse. If your eval harness resets context per task and your production agent doesn't, you're not testing what you ship.&lt;/p&gt;

&lt;p&gt;The fix isn't complicated, it's just work. Run your evals through the exact context-management path your production agent uses. Same compaction, same memory, same tool wiring. If the provider offers a native session or reasoning-state feature, test with it on and off, and record both, the way ARC Prize now does. If the two numbers are close, great, your harness isn't the variable. If they're 30 points apart, you've just learned where your engineering time should go, and it isn't prompt tweaking.&lt;/p&gt;

&lt;p&gt;Simon Willison's &lt;a href="https://simonwillison.net/2026/Sep/3/gpt6-astra/" rel="noopener noreferrer"&gt;note on the Astra launch&lt;/a&gt; makes the related point that Astra's headline benchmark wins arrived alongside Artificial Analysis still ranking it below Fable 5.1 on their intelligence index. Different harnesses, different questions, different winners. That's not a contradiction. That's what measuring agents looks like now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing about it this week
&lt;/h2&gt;

&lt;p&gt;Two things, and you can steal both.&lt;/p&gt;

&lt;p&gt;First, I'm adding a "harness" column to my eval results table. Every run records which context policy it used. I've been treating that as an implementation detail. It's a variable, and it's a bigger one than the model version in some cases.&lt;/p&gt;

&lt;p&gt;Second, I'm re-running my agent evals with provider-native session state turned on, for the two providers I use that support it, and comparing against my hand-rolled note-passing. I have a suspicion my notes approach is the Standard harness of my own little world and I've been paying a tax I didn't know about.&lt;/p&gt;

&lt;p&gt;If you want to argue with any of this, the &lt;a href="https://arcprize.org/results/openai-gpt-6-astra" rel="noopener noreferrer"&gt;replays are public&lt;/a&gt; and you can &lt;a href="https://arcprize.org/tasks/ls20" rel="noopener noreferrer"&gt;play the games yourself&lt;/a&gt;. I lasted about four minutes on the first one before I understood the rules, which puts me somewhere behind Astra and, I suspect, most of the 500 test participants. This is the kind of evaluation work I do on the agent projects I take on; there's more about that on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/arc-agi-3-gpt-6-astra-two-scores-one-model-what-the-harness-gap-means/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>arcagi3</category>
      <category>gpt6astra</category>
      <category>llmevaluation</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>GitHub Actions Reusable Workflows: The Bug I Fixed Eleven Times</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:39:51 +0000</pubDate>
      <link>https://dev.to/abyzgenic/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times-2n7i</link>
      <guid>https://dev.to/abyzgenic/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times-2n7i</guid>
      <description>&lt;p&gt;Okay, this is going to sound dumb, but last month I fixed the same bug in eleven repositories. Not eleven bugs. One bug, eleven times. A Node version pin in a CI workflow that I'd copy-pasted across every client project I set up in 2024, and every one of them started failing the week a dependency dropped support for Node 18.&lt;/p&gt;

&lt;p&gt;Eleven pull requests. Eleven "bump node to 22" commits. Eleven times waiting for the green tick. Somewhere around repo seven I stopped and asked myself why I was doing this by hand when GitHub Actions has had reusable workflows since 2021 and I had, apparently, never bothered to learn them properly.&lt;/p&gt;

&lt;p&gt;So this is the post I should have read three years ago. It covers what reusable workflows are, how they differ from composite actions (the two get confused constantly, including by me), the before and after of my own setup, and the handful of gotchas that cost me an afternoon. If you maintain more than three repos with CI, you probably have this problem too, you just haven't hit the eleven-PR morning yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy-paste workflow, and why it rots
&lt;/h2&gt;

&lt;p&gt;Here's roughly what lived in &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; in every one of those repos. You've written this file. Everyone has.&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="c1"&gt;# Before: the same file, pasted into every repo&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&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;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@v4&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/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&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&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 lint&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 test -- --coverage&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coverage&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coverage/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing wrong with it on day one. The rot sets in slowly. One repo gets a fix for flaky caching. Another gets a matrix added because a client needed Node 20 support. A third gets a security tweak after I read something about pinning action versions to SHAs. None of those improvements travel. Six months later I have eleven workflows that are 80% identical and 20% mysteriously different, and I couldn't tell you which one is the "good" one.&lt;/p&gt;

&lt;p&gt;The Node pin was just the symptom that finally hurt enough to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusable workflows vs composite actions
&lt;/h2&gt;

&lt;p&gt;This is where I got stuck the first time, so let me be blunt about the distinction before showing code.&lt;/p&gt;

&lt;p&gt;A reusable workflow is an entire workflow, with its own jobs and runners, that another workflow can call as if it were a job. You trigger it with &lt;code&gt;workflow_call&lt;/code&gt;, and the caller uses &lt;code&gt;uses:&lt;/code&gt; at the job level. The &lt;a href="https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows" rel="noopener noreferrer"&gt;GitHub docs on reusing workflows&lt;/a&gt; are decent here, but they bury the important part: the called workflow runs in the context of the caller's repository, on the caller's runners, with the caller's secrets if you pass them.&lt;/p&gt;

&lt;p&gt;A composite action is a bundle of steps. It runs inside a job that already exists. You can't define a runner or a matrix in a composite action, because it doesn't own the job. It's closer to a function you call from within a step list. The &lt;a href="https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action" rel="noopener noreferrer"&gt;composite action docs&lt;/a&gt; cover the mechanics.&lt;/p&gt;

&lt;p&gt;The rule I use now: if the thing I want to share is "a job" (test this, build this, deploy this), it's a reusable workflow. If it's "a few steps that always go together" (set up Node with our cache config, or log in to our registry), it's a composite action. I ended up needing both, and they nest fine: a reusable workflow can call composite actions.&lt;/p&gt;

&lt;p&gt;I got this wrong for two weeks by trying to make one composite action do everything, then wondering why I couldn't give it a matrix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The after: one workflow, one line per repo
&lt;/h2&gt;

&lt;p&gt;I made a repo called &lt;code&gt;qasim/workflows&lt;/code&gt; (public, because reusable workflows in private repos need extra access config and I didn't want to think about it yet). The shared workflow lives at &lt;code&gt;.github/workflows/node-ci.yml&lt;/code&gt;:&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="c1"&gt;# .github/workflows/node-ci.yml in the shared repo&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Node CI (reusable)&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-versions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JSON array of Node versions to test&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;["22"]'&lt;/span&gt;
      &lt;span class="na"&gt;run-lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&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;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJSON(inputs.node-versions) }}&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@v4&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/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.node }}&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&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&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.run-lint }}&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 lint&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 test ${{ inputs.coverage &amp;amp;&amp;amp; '-- --coverage' || '' }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.coverage }}&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coverage-node${{ matrix.node }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coverage/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in each of the eleven repos, &lt;code&gt;ci.yml&lt;/code&gt; became this:&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="c1"&gt;# After: the caller, in every project repo&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ci&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;qasim/workflows/.github/workflows/node-ci.yml@v1&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-versions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;["20",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"22"]'&lt;/span&gt;
      &lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight lines. The next time a Node version dies, I change one file, tag &lt;code&gt;v1.1&lt;/code&gt;, and move the &lt;code&gt;v1&lt;/code&gt; tag. Every repo picks it up on its next run.&lt;/p&gt;

&lt;p&gt;Notice the matrix lives in the shared workflow but the versions come from the caller. That's the bit composite actions can't do, and it's the reason I picked reusable workflows for this layer. The &lt;code&gt;fromJSON&lt;/code&gt; trick is ugly but it's the documented way to pass an array through a string input; matrix inputs can't be typed as arrays directly. Yes, I find that annoying too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets, permissions, and the &lt;code&gt;inherit&lt;/code&gt; shortcut
&lt;/h2&gt;

&lt;p&gt;Two things bit me here.&lt;/p&gt;

&lt;p&gt;First, secrets don't flow automatically. The called workflow only sees what you explicitly pass in the &lt;code&gt;secrets:&lt;/code&gt; block, unless you write &lt;code&gt;secrets: inherit&lt;/code&gt;, which hands over everything the caller has access to. I used &lt;code&gt;inherit&lt;/code&gt; for a day, then changed my mind. It's convenient, but it means the shared workflow can read every secret in every repo that calls it, and if I ever add a step that echoes environment for debugging (I have, more than once), that's a bad afternoon. Passing secrets by name costs one extra line per secret and lets me see exactly what crosses the boundary.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;permissions&lt;/code&gt;. If your shared workflow needs to write to the repo (pushing a tag, commenting on a PR), the caller has to grant that. The called workflow can only narrow permissions, never widen them beyond what the caller allowed. GitHub's &lt;a href="https://docs.github.com/en/actions/reference/security/secure-use" rel="noopener noreferrer"&gt;secure use guide for Actions&lt;/a&gt; covers the reasoning, and the short version is: put &lt;code&gt;permissions: contents: read&lt;/code&gt; at the top of your caller and only open things up per job when you have to.&lt;/p&gt;

&lt;p&gt;On the topic of security, pin the shared workflow reference to something you control. &lt;code&gt;@v1&lt;/code&gt; is a moving tag I own, so I'm fine with it. If you're calling someone else's reusable workflow, pin to a full commit SHA. Same rule as third-party actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composite actions for the boring steps
&lt;/h2&gt;

&lt;p&gt;Once the job-level workflow was shared, I noticed the same four setup steps kept appearing in my other workflows too: the deploy workflow, the release workflow, a nightly one. Node setup, cache, &lt;code&gt;npm ci&lt;/code&gt;, registry auth. That's a composite action.&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="c1"&gt;# action.yml in qasim/workflows/setup-node-project/&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node project&lt;/span&gt;
&lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
  &lt;span class="na"&gt;npm-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
&lt;span class="na"&gt;runs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;using&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composite&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/setup-node@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.node-version }}&lt;/span&gt;
        &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&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&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.npm-token }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Called with &lt;code&gt;uses: qasim/workflows/setup-node-project@v1&lt;/code&gt;. The &lt;code&gt;shell: bash&lt;/code&gt; line is required on every &lt;code&gt;run&lt;/code&gt; step in a composite action, and forgetting it produces an error message that does not mention the word "shell". You've been warned.&lt;/p&gt;

&lt;p&gt;One genuine limitation: composite actions can't use &lt;code&gt;secrets.*&lt;/code&gt; directly. You pass the secret in as an input from the caller, which is what the &lt;code&gt;npm-token&lt;/code&gt; input is doing above. It feels slightly wrong to pass a secret as an input, but it's how the model works, and the value is still masked in logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching still needs care
&lt;/h2&gt;

&lt;p&gt;I assumed moving to a shared workflow would make caching "just work" everywhere. It mostly did, with one wrinkle: cache keys are scoped to the repo that runs the workflow, not the repo that defines it. That's the right behaviour, and it means each of the eleven repos has its own &lt;code&gt;node_modules&lt;/code&gt; cache, which is what you want. But the 10 GB per-repo cache limit that GitHub documents in its &lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching" rel="noopener noreferrer"&gt;dependency caching reference&lt;/a&gt; applies per caller, so a shared workflow that caches aggressively can fill a small repo's quota faster than you'd expect. I trimmed the coverage artifacts to seven days of retention and stopped caching Playwright browsers in the shared job. Not a big deal, just not free.&lt;/p&gt;

&lt;p&gt;If you're also shrinking Docker images in CI, the same "measure before you assume" attitude applies. I wrote about that in my post on &lt;a href="https://abrarqasim.com/blog/docker-multi-stage-builds-the-1-2gb-image-i-stopped-shipping" rel="noopener noreferrer"&gt;multi-stage builds and the 1.2 GB image I stopped shipping&lt;/a&gt;, and about half of that post's advice is really about what your CI cache is silently doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting today
&lt;/h2&gt;

&lt;p&gt;Don't extract too early. I wrote the copy-paste version of that workflow in maybe fifteen minutes back in 2024 and it served me fine for a year. The shared version took most of a day, including the two weeks of low-level confusion about composite actions that I'm compressing here. Three repos with slightly different CI is fine. Eight is where it starts to hurt. Eleven is where you write a blog post about it.&lt;/p&gt;

&lt;p&gt;Also, version the shared repo like a library. Moving &lt;code&gt;v1&lt;/code&gt; is fine for backward-compatible fixes. Anything that changes an input name gets &lt;code&gt;v2&lt;/code&gt;, and the old tag stays around. I learned this the cheap way, by breaking one repo instead of all of them, because one repo happened to still pin &lt;code&gt;@main&lt;/code&gt;. Don't pin &lt;code&gt;@main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I'm still not sure whether I should have just used a monorepo for the client projects and skipped all of this. Probably not; the clients own their repos and want them separate. But I wrote about that tradeoff at length in the &lt;a href="https://abrarqasim.com/blog/turborepo-vs-nx-the-question-i-ask-before-either-one" rel="noopener noreferrer"&gt;Turborepo vs Nx post&lt;/a&gt;, and the honest answer is that shared CI workflows are the "we have multiple repos and that's not changing" solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Something to do this week
&lt;/h2&gt;

&lt;p&gt;Open your most-copied workflow file. Grep your org for its job name. If it appears in more than three repos, make a &lt;code&gt;workflows&lt;/code&gt; repo, move that job into a &lt;code&gt;workflow_call&lt;/code&gt; file with two or three inputs, and replace the copies with an eight-line caller. Tag it &lt;code&gt;v1&lt;/code&gt;. You'll spend an hour, and the next time a runtime version dies you'll fix it once.&lt;/p&gt;

&lt;p&gt;If you want to see how I set up CI for the projects I run for clients, the &lt;a href="https://abrarqasim.com/work" rel="noopener noreferrer"&gt;work section of my site&lt;/a&gt; has a few examples where the pipeline setup was most of the job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>devops</category>
      <category>reusableworkflows</category>
    </item>
    <item>
      <title>When Not to Use AI Automation: The Refund Bot That Cost Me a Client</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 11 Sep 2026 14:39:51 +0000</pubDate>
      <link>https://dev.to/abyzgenic/when-not-to-use-ai-automation-the-refund-bot-that-cost-me-a-client-2n7k</link>
      <guid>https://dev.to/abyzgenic/when-not-to-use-ai-automation-the-refund-bot-that-cost-me-a-client-2n7k</guid>
      <description>&lt;p&gt;Confession: the first AI automation I sold to a client was a mistake, and I didn't know it for four months. It was a small e-commerce shop, three people, and they wanted the "AI reads the support inbox and replies" thing everyone was selling in 2024. I built it. It worked in the demo. Then it spent a quarter politely telling customers their refund was "being processed" when nobody had processed anything, because the model was excellent at sounding like a refund had been processed and nobody had wired it to the actual refund system. The owner found out from a one-star review.&lt;/p&gt;

&lt;p&gt;I've been thinking about that project again this week, for an odd reason. OpenAI published a &lt;a href="https://openai.com/index/gilbert-tobin/" rel="noopener noreferrer"&gt;case study on Gilbert + Tobin&lt;/a&gt;, a large Australian law firm that has rolled ChatGPT Enterprise and Codex across most of its operations. It's a vendor story, so take the adoption numbers with the usual salt. But the interesting part isn't the numbers. It's what they explicitly did not automate, and how much governance a firm with real money and real lawyers decided it needed before letting AI touch anything. If a law firm with a CIO and a business transformation team draws the line that carefully, a three person shop with me as the entire IT department should probably draw it more carefully, not less.&lt;/p&gt;

&lt;p&gt;So this is a post about when not to use AI automation in a small business, written by someone who makes part of his living building AI automation for small businesses. That should tell you I'm not against it. I'm against the version of it that ends in a one-star review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a law firm chose not to automate
&lt;/h2&gt;

&lt;p&gt;Read the Gilbert + Tobin piece with an eye for the negatives and a pattern shows up. The firm introduced ChatGPT to operations teams first, then marketing, recruitment, finance and technology. The legal work itself, the advice clients pay for, stayed on separate approved platforms with their own controls. The CEO's line was "AI is not cheating", but the framing throughout is that people stay responsible for constraining the task, checking outputs and approving the final work product.&lt;/p&gt;

&lt;p&gt;Their Codex examples are the same shape. Audit reports across 300 entities, prepared by Codex and reviewed by a person. Eleven hundred files checked and renamed for upload, reviewed by a person. A KYC and anti-money-laundering check workflow that does the research and processing steps and then produces a report for human sign-off. In every example the model does the long boring middle and a human owns the start and the end.&lt;/p&gt;

&lt;p&gt;There's a detail I keep coming back to. They built a custom GPT trained on the CEO's writing and priorities so executives could pressure test ideas before taking up his time. And then the case study goes out of its way to say it does not make decisions on his behalf or speak for him. Someone thought hard about where that line was and wrote it down.&lt;/p&gt;

&lt;p&gt;That's a firm with hundreds of staff, an information security function, and clients who will sue if data leaks. My clients are usually a founder, two employees and a Shopify store. They don't have a governance team. So the question for me is: which of those lines transfer down to a business that size, and which ones are big company overhead?&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that transfers: automate the middle, not the ends
&lt;/h2&gt;

&lt;p&gt;Almost every AI automation I've seen fail in a small business failed at one of the ends. Either the model decided what the task was, or the model decided the task was done. The middle, the part where it reads forty PDFs and pulls the invoice numbers into a spreadsheet, almost never fails in a way that matters. If it gets one wrong, a person catches it at the end, because a person is at the end.&lt;/p&gt;

&lt;p&gt;My refund bot broke both ends at once. It decided a support email was a refund request (start), and it decided to tell the customer the refund was handled (end). The bit in the middle, drafting a polite reply, was flawless. That was the problem. A flawless middle with no one at the ends is the most dangerous configuration there is, because it looks like it's working right up until it very much isn't.&lt;/p&gt;

&lt;p&gt;So the first rule I now apply, before a single line of code, is this. Draw a box around the automation. Write down what enters the box and who decided it should. Write down what leaves the box and who checks it before it has an effect in the real world. If either of those "who" answers is "the model", stop and ask whether the business can survive the model being confidently wrong there a hundred times before anyone notices. Usually it can't.&lt;/p&gt;

&lt;p&gt;I wrote about the positive version of this, the jobs that actually are worth handing to a model, in my post on &lt;a href="https://abrarqasim.com/blog/ai-automation-examples-skills-llms-replace" rel="noopener noreferrer"&gt;AI automation examples and which skills LLMs replace&lt;/a&gt;. This post is the mirror image.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use AI automation at all
&lt;/h2&gt;

&lt;p&gt;Some things aren't a middle. I've built up a short list of situations where I'll now tell a client no, or at least "not like that", and most of them come from getting it wrong once.&lt;/p&gt;

&lt;p&gt;Anything that sends money or makes a commitment. Refunds, discounts, delivery promises, quotes. A model that promises a customer a discount has made a contract on your behalf, and "the AI said it" is not a defence anyone will accept. Gilbert + Tobin let Codex prepare audit reports, not sign them. The same applies to a Shopify store's refund policy.&lt;/p&gt;

&lt;p&gt;Anything where being wrong is invisible. A summarizer that drops one line from a contract, a classifier that files a complaint under "general enquiry". Nobody sees the mistake at the moment it happens. The cost turns up weeks later as a churned customer or a missed deadline, and you can't trace it back. If you can't measure the error rate, you can't decide whether it's acceptable, and "we'll find out" is not a plan.&lt;/p&gt;

&lt;p&gt;Anything you don't do often enough to review. This one surprised me. The Gilbert + Tobin examples are irregular jobs: an audit sweep, a bulk rename. Those work because a person is sitting right there when it runs. The danger zone is the low volume, unattended automation. It runs twice a week, no one is watching, and by the time someone looks at the output there are two months of confidently wrong records to unpick. High volume tasks get caught fast because someone is always looking. Low volume, unattended tasks rot.&lt;/p&gt;

&lt;p&gt;Anything where the business hasn't written down the rule. If the founder can't tell me in plain English how they decide whether a lead is worth calling, I can't encode it, and the model will happily invent a rule that sounds plausible. Half the "AI automation" projects I've turned down were really "please make a decision for us that we haven't made ourselves" projects. That's a consulting job, and the model is not the consultant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permission problem nobody budgets for
&lt;/h2&gt;

&lt;p&gt;The part of this that doesn't get talked about in small business AI automation pitches is what the automation is allowed to touch. Docker published a piece this week, &lt;a href="https://www.docker.com/blog/below-the-harness-governing-a-multi-model-multi-harness-world/" rel="noopener noreferrer"&gt;Below the Harness&lt;/a&gt;, that opens with Norm Hardy's 1988 &lt;a href="https://en.wikipedia.org/wiki/Confused_deputy_problem" rel="noopener noreferrer"&gt;confused deputy problem&lt;/a&gt;: a program that takes an action using its own permissions rather than yours. Their argument is that every AI agent is that deputy now, because it inherits your credentials, your repository access, your ability to call whatever API you can call, and that organizations will need a runtime layer underneath all their agents where rules get enforced in one place.&lt;/p&gt;

&lt;p&gt;That's aimed at enterprises running fleets of agents. But the confused deputy shows up in a three person business just as hard, and with fewer safeguards. The typical small business automation I'm asked to build runs on the owner's own accounts. The owner's Gmail. The owner's Stripe key. The owner's admin login to the store. Because setting up a scoped service account is friction and the demo is due Friday. So the automation can do anything the owner can do, which includes issuing refunds, deleting products and emailing the entire customer list. It probably won't. But "probably won't" is doing a lot of work when the thing deciding what to do is a model reading customer emails, some of which will eventually contain instructions.&lt;/p&gt;

&lt;p&gt;I covered the technical shape of that last risk in &lt;a href="https://abrarqasim.com/blog/ai-agent-security-tool-schema-problem" rel="noopener noreferrer"&gt;the tool schema problem in AI agent security&lt;/a&gt;, so I won't repeat it. The small business version is simpler and more boring. Before any automation goes live, it gets its own account, with the minimum permissions it needs, and the owner's credentials come out of the config. If the client says that's too much hassle, that's useful information about whether the automation should exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the small version of governance actually looks like
&lt;/h2&gt;

&lt;p&gt;I don't think a three person company needs a governance team. I do think it needs about a page of writing, and I now refuse to build the automation until that page exists. It has four things on it.&lt;/p&gt;

&lt;p&gt;What the automation is allowed to do, in plain sentences. "Read incoming support emails. Draft a reply. Put the draft in a folder. Never send." Not a system prompt, a sentence a non-technical owner can read and agree to.&lt;/p&gt;

&lt;p&gt;What it's not allowed to do, which is usually the previous list with the word "never" in front of the scary verbs. Never send. Never refund. Never change a price.&lt;/p&gt;

&lt;p&gt;How you'll know it's wrong. Somebody's name, and how often they'll look. "Sara checks the drafts folder every morning before sending." If nobody's name goes here, the automation is unattended, and see above.&lt;/p&gt;

&lt;p&gt;How to turn it off. A single switch the owner can flip without calling me. This sounds trivial. It is the most important line on the page. The refund bot took a week to fully stop because it was wired into three places and only I knew where.&lt;/p&gt;

&lt;p&gt;That page is the small business translation of everything in the Gilbert + Tobin story: approved tasks, what you can enter, how outputs get reviewed, and who is accountable. It takes an hour to write and it has saved me from at least two projects that would have gone the way of the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI automation does earn its keep
&lt;/h2&gt;

&lt;p&gt;I want to end on the other side, because "don't" is a cheap thing to write and I'd be a hypocrite if I stopped there.&lt;/p&gt;

&lt;p&gt;The automations I've built that clients still pay for a year later have all had the same profile. High volume, so mistakes surface fast. A human at both ends. Read-mostly, meaning the model's job is to look at things and produce a draft or a classification, and the effect on the world happens when a person clicks. Invoice data extraction into a review queue. First draft replies that a person sends. Lead notes summarized before a call. Tagging and sorting a backlog so a person can triage it in ten minutes instead of an hour.&lt;/p&gt;

&lt;p&gt;None of these are exciting. All of them save real hours, and all of them have a fail state that is "a person spends an extra minute" rather than "a customer gets a promise nobody will keep". The cost side matters too; a review queue that a person clears is far cheaper to run than an agent loop that retries until it's sure, and I've written before about &lt;a href="https://abrarqasim.com/blog/llm-cost-optimization-after-the-free-lunch-ended" rel="noopener noreferrer"&gt;what happened to LLM costs after the free lunch ended&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're a small business owner reading this and someone is pitching you an automation, ask them where the human at each end is. If they say there doesn't need to be one, that's your answer. If you're a developer building these things, the way I've come to think about scoping them is written up in more detail on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, along with the projects where I learned it the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;Pick one automation you're already running, or the one you're about to build, and write the one page. Four sections: allowed, never, who checks and how often, how to turn it off. If you can't fill in "who checks", don't ship it. If you can't fill in "how to turn it off" in under a sentence, you've got a bigger problem than AI, and this is a good week to fix it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/when-not-to-use-ai-automation-the-refund-bot-that-cost-me-a-client/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiautomation</category>
      <category>smallbusiness</category>
      <category>aigovernance</category>
      <category>agents</category>
    </item>
    <item>
      <title>Laravel 13.30: chunkBy() and the Storage::path() Line I Had in Production</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:36:25 +0000</pubDate>
      <link>https://dev.to/abyzgenic/laravel-1330-chunkby-and-the-storagepath-line-i-had-in-production-3a3n</link>
      <guid>https://dev.to/abyzgenic/laravel-1330-chunkby-and-the-storagepath-line-i-had-in-production-3a3n</guid>
      <description>&lt;p&gt;Short version for the impatient: Laravel 13.30 gives collections a &lt;code&gt;chunkBy()&lt;/code&gt; method, and it quietly closes a hole where &lt;code&gt;Storage::path()&lt;/code&gt; would happily hand you a filesystem path to your &lt;code&gt;.env&lt;/code&gt; file. If you have a download endpoint that takes a path from the request, go grep for &lt;code&gt;Storage::path&lt;/code&gt; before you read the rest of this.&lt;/p&gt;

&lt;p&gt;Now the longer version. I almost skipped this release. Point releases in Laravel land are a weekly thing now, and most of them are the kind of change you nod at and forget. I opened the changelog on Wednesday morning mostly out of habit, saw "chunkBy", thought "nice, another helper", and was about to close the tab. Then I got to the part about &lt;code&gt;Storage::path()&lt;/code&gt; and went a bit cold, because I had written the exact vulnerable line in a client project in 2023. I checked. It had been refactored out since, but not because anyone noticed the problem. We just moved to signed URLs for other reasons. That's luck, not engineering.&lt;/p&gt;

&lt;p&gt;So this post is two things. A look at &lt;code&gt;chunkBy()&lt;/code&gt;, because I like collection helpers and this one replaces a pattern I've typed dozens of times. And a slightly more serious look at what changed in &lt;code&gt;Storage::path()&lt;/code&gt;, why it was inconsistent with the rest of the filesystem layer for years, and what it might break when you upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  What chunkBy() replaces
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;a href="https://laravel.com/docs/13.x/collections" rel="noopener noreferrer"&gt;collections&lt;/a&gt; have had &lt;code&gt;chunk()&lt;/code&gt; forever. It splits a collection into pieces of a fixed size. Useful for batching, useless for grouping adjacent items that share something.&lt;/p&gt;

&lt;p&gt;For that, the tool since Laravel 8 has been &lt;code&gt;chunkWhile()&lt;/code&gt;. It takes a callback and starts a new chunk whenever the callback returns false. Every time I've used it, the callback compared the current item to the last item of the chunk being built:&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: chunkWhile with the comparison you always end up writing&lt;/span&gt;
&lt;span class="nv"&gt;$grouped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkWhile&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;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$chunk&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;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works. It also has a shape I have to re-derive every time. Which argument is the chunk? Is it &lt;code&gt;-&amp;gt;last()&lt;/code&gt; or &lt;code&gt;-&amp;gt;first()&lt;/code&gt; I want? Does &lt;code&gt;==&lt;/code&gt; or &lt;code&gt;===&lt;/code&gt; matter for the ids coming out of this particular database driver? None of those are hard questions, but they're the kind of questions that cost me a minute of staring each time, and a minute of staring is exactly what a helper should remove.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chunkBy()&lt;/code&gt; is that comparison as a method:&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;// After: chunkBy with a key&lt;/span&gt;
&lt;span class="nv"&gt;$grouped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Or a callback, if the grouping value needs computing&lt;/span&gt;
&lt;span class="nv"&gt;$grouped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkBy&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;$order&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;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Dot notation works, because the key goes through data_get()&lt;/span&gt;
&lt;span class="nv"&gt;$grouped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$addresses&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'address.city'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details from the &lt;a href="https://github.com/laravel/framework/pull/61357" rel="noopener noreferrer"&gt;pull request&lt;/a&gt; that I care about. The key resolves via &lt;code&gt;data_get()&lt;/code&gt;, so nested keys and objects behave the way they do everywhere else in the framework. And the original keys are preserved inside each chunk, which matters if you're chunking a keyed collection and want to write the results back somewhere by id.&lt;/p&gt;

&lt;p&gt;It also lands on &lt;code&gt;LazyCollection&lt;/code&gt;, which is the version I'll use most. A common job I write is "walk a big export in order, emit one file per group". With &lt;code&gt;chunkWhile&lt;/code&gt; on a lazy collection I always had a nagging feeling that the &lt;code&gt;$chunk-&amp;gt;last()&lt;/code&gt; lookup was doing more work than it needed to. With &lt;code&gt;chunkBy&lt;/code&gt; I stop thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing to remember: it's adjacency, not grouping
&lt;/h2&gt;

&lt;p&gt;Here's where I'll push back on how I've seen this described online already. &lt;code&gt;chunkBy&lt;/code&gt; is not &lt;code&gt;groupBy&lt;/code&gt; with a different name. It only groups items that are next to each other.&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="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;chunkBy&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;$v&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;$v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [[1, 1], [2, 2], [1, 1]]  &amp;lt;- three chunks, not two&lt;/span&gt;

&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;groupBy&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;$v&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;$v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [1 =&amp;gt; [1, 1, 1, 1], 2 =&amp;gt; [2, 2]]  &amp;lt;- two groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole point of it, and it's the whole trap. If your query doesn't have an &lt;code&gt;orderBy&lt;/code&gt; on the column you're chunking by, you'll get fragmented chunks and probably not notice in dev because your seed data happens to be sorted. I have been bitten by this with &lt;code&gt;chunkWhile&lt;/code&gt; and I'll be bitten by it with &lt;code&gt;chunkBy&lt;/code&gt;. The fix is boring: sort first, then chunk, and put a test in with deliberately shuffled input.&lt;/p&gt;

&lt;p&gt;When do you want adjacency instead of a full group? Usually when the order carries meaning. Consecutive log lines from the same request. Runs of the same status in a timeline. Line items on an invoice that should be visually grouped under a parent without being reordered. &lt;code&gt;groupBy&lt;/code&gt; would destroy the sequence; &lt;code&gt;chunkBy&lt;/code&gt; keeps it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage::path() finally goes through the normalizer
&lt;/h2&gt;

&lt;p&gt;This is the part that made me check old projects.&lt;/p&gt;

&lt;p&gt;Every filesystem call on a Laravel disk goes through Flysystem, and Flysystem normalizes the path before doing anything with it. If the normalized path resolves outside the disk root, it throws &lt;code&gt;PathTraversalDetected&lt;/code&gt;. So this has been rejected for years:&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="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'../../../.env'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// throws PathTraversalDetected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Storage::path()&lt;/code&gt; was the one exception. It didn't call into the driver's read or write methods, it just asked the &lt;code&gt;PathPrefixer&lt;/code&gt; to glue the disk root onto whatever string you gave it. No normalization, no traversal check. So on the default local disk:&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 13.30&lt;/span&gt;
&lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'../../../.env'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "/var/www/app/storage/app/../../../.env"&lt;/span&gt;
&lt;span class="c1"&gt;// which is a perfectly valid native path to your .env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt; and &lt;code&gt;readStream()&lt;/code&gt; all refused that argument. &lt;code&gt;path()&lt;/code&gt; returned a string pointing at your secrets. And the string is where it gets dangerous, because &lt;code&gt;path()&lt;/code&gt; is the method you reach for when you want to hand a file to something that isn't Flysystem. Like PHP's own file functions. Or a response:&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;// The line I had in a 2023 project. Do not do this.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'path'&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;response()-&amp;gt;download()&lt;/code&gt; doesn't know or care about disk roots. It just streams whatever native path you give it. So the one Laravel method that skipped the traversal check was also the one most likely to be sitting directly in front of &lt;code&gt;fopen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As of &lt;a href="https://github.com/laravel/framework/pull/61343" rel="noopener noreferrer"&gt;#61343&lt;/a&gt;, &lt;code&gt;path()&lt;/code&gt; runs the argument through &lt;code&gt;WhitespacePathNormalizer&lt;/code&gt;, the same normalizer that &lt;code&gt;League\Flysystem\Filesystem&lt;/code&gt; builds for every other operation. Relative segments now resolve the way the driver resolves them, and anything that escapes the root throws instead of returning a string.&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;// After 13.30&lt;/span&gt;
&lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reports/../invoices/2026-08.pdf'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; "/var/www/app/storage/app/invoices/2026-08.pdf"  (resolved inside the root, fine)&lt;/span&gt;

&lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'../../../.env'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// throws League\Flysystem\PathTraversalDetected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be fair here. This was a known inconsistency rather than a hidden zero-day, and a lot of teams never hit it because they don't pass request input to &lt;code&gt;path()&lt;/code&gt;. But "a lot of teams" isn't "all teams", and I was on one of the other kind. I wrote about the general shape of this problem, where the tool you use for the review is only as good as the surface it knows about, in my post on &lt;a href="https://abrarqasim.com/blog/laravel-security-audit-with-the-agent-you-already-use" rel="noopener noreferrer"&gt;running a Laravel security audit with the agent you already use&lt;/a&gt;. That audit would have flagged the &lt;code&gt;download()&lt;/code&gt; line. It would not have flagged &lt;code&gt;path()&lt;/code&gt; itself as the cause, because until this week, &lt;code&gt;path()&lt;/code&gt; behaving that way was just how it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this might break on upgrade
&lt;/h2&gt;

&lt;p&gt;The flip side of a hardening change is that code depending on the old behaviour now throws.&lt;/p&gt;

&lt;p&gt;If you have anything that deliberately uses &lt;code&gt;..&lt;/code&gt; in a path given to &lt;code&gt;Storage::path()&lt;/code&gt;, it will now either resolve (if the result stays inside the root) or throw (if it doesn't). The first case is silent and probably fine. The second case is an exception in production, and the framework isn't going to guess whether you meant it.&lt;/p&gt;

&lt;p&gt;The pattern I've seen most is a "shared" folder one level above the disk root that someone reached with &lt;code&gt;Storage::path('../shared/thing.csv')&lt;/code&gt; because configuring a second disk felt like too much ceremony. That will now throw. The fix is to configure the second disk. It was always the right answer, it just wasn't enforced.&lt;/p&gt;

&lt;p&gt;The other one to watch is tests. If you have a test that asserts &lt;code&gt;Storage::path()&lt;/code&gt; returns a specific string for a path with relative segments, the string changes even when nothing throws, because the segments now collapse. Search your test suite for &lt;code&gt;Storage::path(&lt;/code&gt; and &lt;code&gt;-&amp;gt;path(&lt;/code&gt; on a disk before you bump.&lt;/p&gt;

&lt;p&gt;My own upgrade checklist for this release, in the order I actually did it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "Storage::path\|-&amp;gt;path(" app/&lt;/code&gt; and read every hit.&lt;/li&gt;
&lt;li&gt;For each hit, ask: can any part of this argument come from a request, a webhook payload, a queued job payload, or a database column a user can edit?&lt;/li&gt;
&lt;li&gt;If yes, either validate the path against an allow list before it reaches &lt;code&gt;path()&lt;/code&gt;, or stop using &lt;code&gt;path()&lt;/code&gt; there entirely and use &lt;code&gt;download()&lt;/code&gt; / &lt;code&gt;response()&lt;/code&gt; on the disk, which go through the normalizer.&lt;/li&gt;
&lt;li&gt;Run the suite. Anything that throws &lt;code&gt;PathTraversalDetected&lt;/code&gt; gets fixed, not caught.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step three is the real one. The 13.30 change means the traversal case throws, which is far better than silently returning a path. But an exception in front of a download endpoint is still a broken endpoint, and I'd rather reject bad input with a 422 than let it reach the filesystem layer at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small one I'll use every day: worker stop reasons
&lt;/h2&gt;

&lt;p&gt;There's a third change in this release that isn't security related and isn't as clever as &lt;code&gt;chunkBy&lt;/code&gt;, but I suspect it'll save me more time than either. &lt;code&gt;queue:work&lt;/code&gt; now prints why the worker stopped as its last line of output.&lt;/p&gt;

&lt;p&gt;Before, a worker that exited just exited. The &lt;code&gt;WorkerStopping&lt;/code&gt; event has carried a &lt;code&gt;WorkerStopReason&lt;/code&gt; for a while, but you had to register a listener to read it, which is a lot of ceremony for something you usually want to know while staring at a terminal at 11pm.&lt;/p&gt;

&lt;p&gt;Now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-01 13:20:40 Worker STOPPED Memory limit exceeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--json&lt;/code&gt; it's a structured record, with the reason, exit code, jobs processed and memory alongside the per-job lines. The &lt;code&gt;WorkerStopReason&lt;/code&gt; enum gained a &lt;code&gt;description()&lt;/code&gt; method covering nine cases, including memory limit, max jobs, max time, queue empty, restart signal and job timeout. Nothing prints under &lt;code&gt;--quiet&lt;/code&gt; or &lt;code&gt;--silent&lt;/code&gt;, so it won't spam a supervisor log.&lt;/p&gt;

&lt;p&gt;This connects to something I wrote about in &lt;a href="https://abrarqasim.com/blog/laravel-queues-2026-defaults-i-stopped-trusting-in-production" rel="noopener noreferrer"&gt;the queue defaults I stopped trusting in production&lt;/a&gt;. A large fraction of "why did my worker die" investigations end with "it hit the memory limit and Horizon restarted it", and until now the evidence for that was indirect. Having the reason in the log line turns a fifteen minute investigation into a glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth a whole post?
&lt;/h2&gt;

&lt;p&gt;I think so, and not because &lt;code&gt;chunkBy&lt;/code&gt; is exciting. It's because the &lt;code&gt;Storage::path()&lt;/code&gt; change is the kind of thing that's easy to miss in a weekly release note, and it has a real "go check your code" action attached to it. Most weeks the answer to "should I read the changelog" is "probably not". This week it was yes.&lt;/p&gt;

&lt;p&gt;A few other things in the release worth a glance if you're on SQL Server (native &lt;code&gt;sqlsrv:&lt;/code&gt; DSN strings now parse instead of getting mangled by &lt;code&gt;parse_url&lt;/code&gt;), or if you build Artisan tooling (&lt;code&gt;Artisan::commandNamed()&lt;/code&gt; resolves a single command without constructing all of them). The full list is in the &lt;a href="https://laravel-news.com/laravel-13-30-0" rel="noopener noreferrer"&gt;Laravel News write-up&lt;/a&gt; and the &lt;a href="https://github.com/laravel/framework/blob/13.x/CHANGELOG.md" rel="noopener noreferrer"&gt;official changelog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to see how I handle file handling and upgrades on client projects more generally, that's covered on my &lt;a href="https://abrarqasim.com/work" rel="noopener noreferrer"&gt;work page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;Run this in every Laravel project you maintain, even the ones not on 13 yet:&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;"Storage::path&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;Storage::disk([^)]*)-&amp;gt;path"&lt;/span&gt; app/ routes/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every line it finds, trace where the argument comes from. If any part of it originates outside your own code, wrap it in validation now. Don't wait for the upgrade to throw the exception for you. Then bump to 13.30, run your suite, and swap your first &lt;code&gt;chunkWhile&lt;/code&gt; comparison for &lt;code&gt;chunkBy&lt;/code&gt; while you're in there. That last one is just for fun.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/laravel-13-30-chunkby-and-the-storage-path-line-i-had-in-production/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>collections</category>
      <category>security</category>
    </item>
  </channel>
</rss>
