<?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: Scott Mallinson</title>
    <description>The latest articles on DEV Community by Scott Mallinson (@scottmallinson).</description>
    <link>https://dev.to/scottmallinson</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%2F363795%2Fb2c6c78d-6421-4bfb-9e02-951768946c39.jpeg</url>
      <title>DEV Community: Scott Mallinson</title>
      <link>https://dev.to/scottmallinson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/scottmallinson"/>
    <language>en</language>
    <item>
      <title>Release pipelines should be boring</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Tue, 16 Jun 2026 13:26:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/release-pipelines-should-be-boring-2jl5</link>
      <guid>https://dev.to/scottmallinson/release-pipelines-should-be-boring-2jl5</guid>
      <description>&lt;p&gt;Release automation tends to get written in a hopeful mood. One pull request merges, one job runs, one tag gets created, everything lands cleanly. You assume only one thing happens at a time. The assumption isn't deliberate. It's just how you think when you're writing the happy path.&lt;/p&gt;

&lt;p&gt;The trouble is that the happy path is a special case. As soon as a repository has busy automated dependency updates, several pull requests can merge within minutes of each other, and each merge fires its own release job. The jobs start from roughly the same point and then race each other to write back. We had a shared GitHub Actions template running releases across a set of repositories, and it turned out to be hiding three separate race conditions, each at a different stage of the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two jobs creating the same tag
&lt;/h2&gt;

&lt;p&gt;The first race is at tag creation. Two jobs start at almost the same moment, both read the current version, both compute the next one, and both try to create the same version tag. One wins. The other fails with a tag conflict.&lt;/p&gt;

&lt;p&gt;The fix is a concurrency group on the workflow. GitHub Actions supports this natively: you name a group, and a run that would join it while another is in flight either waits or gets cancelled. For releases you want it to wait. You're serialising, not skipping.&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;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, two release jobs in the same repo queue instead of colliding. The second runs cleanly once the first is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two jobs pushing to the same branch
&lt;/h2&gt;

&lt;p&gt;Serialising tag creation doesn't cover the last step: pushing the version-bump commit back to the branch. A job checks out the repo, bumps the version, commits, and pushes. If another job pushed in the gap between checkout and push, the working copy is now a commit behind, and git correctly refuses the non-fast-forward push.&lt;/p&gt;

&lt;p&gt;There are two halves to fixing this. The first is to sync with the remote at the last possible moment before writing, so the bump lands on a current view of the branch:&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="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;Sync with remote before versioning&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;git pull --rebase origin ${{ github.ref_name }}&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;Bump version&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 version patch --no-git-tag-version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters. Pull and rebase before the bump, so you're rebasing onto current remote state rather than dragging your version commit over changes that might conflict with it.&lt;/p&gt;

&lt;p&gt;The second half is to make the push itself resilient. Even with a pre-push sync, two jobs can reach the push inside the same narrow window. So the push step retries: on a non-fast-forward rejection it pulls, rebases the bump onto the new tip, and tries again, bounded so it fails loudly instead of looping forever.&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="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;Push Changes&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;for attempt in 1 2 3; do&lt;/span&gt;
      &lt;span class="s"&gt;if git push origin HEAD:${{ github.ref_name }}; then&lt;/span&gt;
        &lt;span class="s"&gt;break&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="s"&gt;if [$attempt -lt 3]; then&lt;/span&gt;
        &lt;span class="s"&gt;echo "Push failed, pulling and retrying..."&lt;/span&gt;
        &lt;span class="s"&gt;git pull --rebase origin ${{ github.ref_name }}&lt;/span&gt;
      &lt;span class="s"&gt;else&lt;/span&gt;
        &lt;span class="s"&gt;echo "Push failed after $attempt attempts"&lt;/span&gt;
        &lt;span class="s"&gt;exit 1&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
    &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that you recover after a failed attempt rather than pulling before every one. Failing first and then recovering avoids unnecessary work, and it avoids a window where a pre-emptive pull could rebase onto a conflicting state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up after a race that already happened
&lt;/h2&gt;

&lt;p&gt;By the time the fixes land, a race can already have left a mess. A job writes a version-bump commit locally but fails to push it, so the version recorded in the package manifest is a step ahead of the tags actually published. Recovering means going through the CI logs to find the run that partially completed, working out what it left behind, and replaying that specific bump cleanly against the current branch. The archaeology takes longer than the fix.&lt;/p&gt;

&lt;p&gt;That's the real cost of partial failures in automation. The failure itself is cheap. Reconstructing the state it leaves behind is what costs you. So you make release steps idempotent where you can: a step that's safe to re-run without doubling its effects turns a tense recovery into a re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a flaky pipeline is worse than it looks
&lt;/h2&gt;

&lt;p&gt;A release job that fails about half the time sits in an awkward blind spot. It's not bad enough to block anyone. You retry, it passes, you move on. The workaround is cheaper than the fix, so the failure gets normalised, and people stop reading it as a signal and start treating it as weather.&lt;/p&gt;

&lt;p&gt;The cumulative cost is real, and it isn't only the wasted retries. An unreliable pipeline changes how people work. If every release risks a babysitting session, the rational move is to batch changes up, which is the opposite of the small, frequent pull requests that make review easier and rollbacks cheaper. A pipeline that runs quietly on every merge takes that disincentive away, and goes back to being something nobody has to think about.&lt;/p&gt;

&lt;p&gt;Doing this in a shared template multiplies the payoff. The fixes land once, and every repository that inherits the template gets them without anyone rediscovering the problem repo by repo, one retry button at a time.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>devops</category>
      <category>automation</category>
    </item>
    <item>
      <title>Granular Dependabot groups and getting error attribution right</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Sat, 13 Jun 2026 20:58:37 +0000</pubDate>
      <link>https://dev.to/scottmallinson/granular-dependabot-groups-and-getting-error-attribution-right-3334</link>
      <guid>https://dev.to/scottmallinson/granular-dependabot-groups-and-getting-error-attribution-right-3334</guid>
      <description>&lt;p&gt;Dependabot has a default behaviour that doesn't scale well: one pull request per outdated dependency. For a repository with a hundred dependencies, a run generates dozens of individual PRs. Most are low-risk patch bumps a developer approves without reading closely — which means either they pile up unreviewed, or people start rubber-stamping them, which defeats the point of the review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why grouping matters
&lt;/h2&gt;

&lt;p&gt;Dependency grouping addresses this. Instead of one PR per package, you define groups — "all AWS SDK packages", "all testing libraries", "everything from this upstream source" — and Dependabot combines the relevant updates into a single PR. The result is far fewer PRs, each giving a complete picture of a set of related changes.&lt;/p&gt;

&lt;p&gt;The work was moving several repositories from ungrouped or coarsely grouped configs to ones with more granular, purposeful groups. The granularity matters: a group defined as "all dependencies" is barely better than no grouping — you still get one huge PR — while groups built around logical cohesion ("packages released together from the same upstream source") give you something you can actually review with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying it across different stacks
&lt;/h2&gt;

&lt;p&gt;The interesting part of rolling this out is that each repo has a different dependency structure. A Node logging library has nothing in common with a .NET error-handling library or a React frontend. For the Node libraries, grouping around major upstream sources makes sense; for the .NET library, the groups follow NuGet namespaces; for the frontend, it's a mix of framework packages, tooling, and application libraries, each with their own natural groupings. You can copy the structure of the YAML across repos, but you still have to think about what actually belongs together in each — and that thinking is the part you can't automate away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error source attribution in a shared library
&lt;/h2&gt;

&lt;p&gt;The other change was about correctness in error reporting. A shared .NET library classifies errors and warnings across several services — capturing not just the message but its origin: which part of the system produced it. The origin is an enum, and one category was missing: a group of trip-related services whose errors weren't covered by any existing value. When those services produced an error, it either failed to classify or fell into a catch-all, making it harder to route the alert and slower to find the responsible team.&lt;/p&gt;

&lt;p&gt;Adding the value is straightforward. The more interesting question is why it was missing. &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;This is a common pattern with shared classification types&lt;/a&gt; — the enum gets defined early, before all the consumers are known, and then isn't kept in sync as new services adopt the library. The fix doesn't just make reporting more accurate; it removes the ambiguity that wastes on-call time. "The origin is unknown" means more digging before anyone can act; "the origin is the trip services layer" means the right team gets paged immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform hardening as a steady-state activity
&lt;/h2&gt;

&lt;p&gt;This is worth naming for what it is: platform hardening. Not new features, not architecture changes, but the continuous work of making the infrastructure more reliable, maintainable, and legible to the people who depend on it. A Dependabot config sits in a file almost nobody reads until dependency updates go wrong; an error-source enum is invisible to end users. Both keep a system that dozens of engineers work in daily manageable over time. The return is long-tailed and largely invisible, which is exactly why it tends to get deprioritised — and doing it consistently, even when nothing more exciting is on the board, is how a platform stays manageable.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>maintenance</category>
    </item>
    <item>
      <title>What adding an AI layer taught me about type ownership</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Tue, 09 Jun 2026 20:12:33 +0000</pubDate>
      <link>https://dev.to/scottmallinson/what-adding-an-ai-layer-taught-me-about-type-ownership-3o40</link>
      <guid>https://dev.to/scottmallinson/what-adding-an-ai-layer-taught-me-about-type-ownership-3o40</guid>
      <description>&lt;p&gt;I've been working on an AI-powered trip planning assistant that sits on top of an existing set of booking microservices. The AI layer is genuinely interesting work — natural language input, iterative trip refinement, the whole thing. But the most valuable engineering work had nothing to do with the AI itself.&lt;/p&gt;

&lt;p&gt;It was about types. Specifically, who owns them and what happens when they drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with local schemas
&lt;/h2&gt;

&lt;p&gt;The AI assistant service had its own validation schema for incoming flight search requests. It wasn't wrong, exactly — it reflected the shape of requests the service expected at the time it was written. But the canonical definition of what a valid flight search request looks like lives in the fare search service, and over time, subtle differences had crept in.&lt;/p&gt;

&lt;p&gt;This is a fairly standard distributed systems problem. When two services independently define what they think the same thing looks like, they'll stay in sync right up until they don't. A field gets added somewhere. A constraint gets tightened. Someone updates one schema and not the other. Nothing breaks immediately — the tests pass, the service starts — but you've created a time bomb.&lt;/p&gt;

&lt;p&gt;The fix was straightforward: pull the shared type out of the fare search package and use that directly in the AI assistant, removing the local definition entirely. One source of truth. When the API evolves, every consumer stays aligned automatically.&lt;br&gt;
Simple to describe. Surprisingly easy to defer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI features make this urgent
&lt;/h2&gt;

&lt;p&gt;Here's the thing about building an AI layer on top of existing services: it doesn't introduce new complexity so much as it surfaces the existing ambiguity.&lt;/p&gt;

&lt;p&gt;A traditional integration between two services fails fast. Service A sends a request to Service B, B returns an error, A logs it, someone gets paged. The feedback loop is tight enough that schema drift tends to get caught reasonably early.&lt;/p&gt;

&lt;p&gt;An AI assistant is different. The user is expressing intent in natural language. The assistant is interpreting that intent, deciding what to query, constructing requests, handling the responses. There are more layers of abstraction between the user's words and the actual API call. When something goes wrong, it might manifest as a confusing or unhelpful response rather than a clear error — which means it can go unnoticed for longer.&lt;/p&gt;

&lt;p&gt;More importantly, the AI layer is making decisions about how to construct requests. If its understanding of what a valid request looks like is subtly out of sync with reality, those decisions will be subtly wrong. Not catastrophically wrong — just wrong enough to be annoying and hard to diagnose.&lt;/p&gt;

&lt;p&gt;This is why schema consolidation that might have felt like a nice-to-have became genuinely urgent once an AI layer was involved. The model compounds every ambiguity downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session state is harder than it looks
&lt;/h2&gt;

&lt;p&gt;The other significant change this week was enforcing a required session-tracking header throughout the conversation service. This one is less about types and more about correctness guarantees.&lt;/p&gt;

&lt;p&gt;The header was already being passed in some code paths. The problem was "some" — in a service where every request needs to carry session context to maintain coherent conversation state, optional isn't good enough. A user iterating on a trip in natural language needs the system to remember where they are in the conversation. If that header gets dropped mid-flow, the session context is gone and the experience breaks in a way that's confusing rather than obvious.&lt;/p&gt;

&lt;p&gt;Making it a validated requirement — something the service explicitly checks for and rejects requests without — is the kind of change that feels bureaucratic until the alternative happens in production.&lt;/p&gt;

&lt;p&gt;The lesson isn't "validate everything". It's more specific: identify the invariants your system actually depends on and make them impossible to violate rather than relying on every caller to get it right.&lt;/p&gt;




&lt;p&gt;If you're planning to add an AI layer on top of an existing set of services, the time to audit your type ownership is before you do it, not after.&lt;/p&gt;

&lt;p&gt;It's not that the AI makes the type problems worse, exactly. It's that it makes them more consequential and harder to spot. A messy schema definition that was fine when the integration was service-to-service becomes a genuine liability when a model is making decisions based on it.&lt;/p&gt;

&lt;p&gt;The boring foundational work — shared types, enforced invariants, single sources of truth — isn't glamorous. But it's what determines whether the interesting work on top of it actually holds up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>The quiet work of removing feature flags</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Fri, 29 May 2026 14:23:43 +0000</pubDate>
      <link>https://dev.to/scottmallinson/the-quiet-work-of-removing-feature-flags-293j</link>
      <guid>https://dev.to/scottmallinson/the-quiet-work-of-removing-feature-flags-293j</guid>
      <description>&lt;p&gt;Feature flags are one of those tools that everyone agrees are useful right up until the point where they're not being used for anything — they're just there, silently complicating the codebase.&lt;/p&gt;

&lt;p&gt;This was a quiet week in terms of new features. Two pull requests, both doing the same kind of thing: removing flags that had outlived their purpose. It's the sort of work that doesn't generate much excitement but, done consistently, makes a meaningful difference to how a codebase feels to work in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What feature flags are actually for
&lt;/h2&gt;

&lt;p&gt;The case for feature flags is well-understood. You decouple your deployment from your release — the code goes out, but the behaviour is gated behind a flag that you can flip remotely. This gives you the ability to run gradual rollouts, do A/B experiments, or kill a feature instantly without touching production. For a platform processing complex travel bookings, with lots of moving parts and no tolerance for downtime, that kind of control is genuinely valuable.&lt;/p&gt;

&lt;p&gt;The problem is that flags don't clean themselves up. Once a feature has fully shipped, once the experiment has concluded and the "enabled" behaviour is the only behaviour you want, the flag becomes noise. It's a conditional in the code with only one meaningful branch. It's a name in a config file that nobody remembers adding. It's a test that exercises a code path that no longer exists in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this work is worth doing deliberately
&lt;/h2&gt;

&lt;p&gt;The risk with flag sprawl isn't any one flag in particular. It's the accumulation. Once you have a dozen old flags in a codebase, each with their own conditional logic and test coverage, a new engineer reading the code has no easy way to know which flags are live, which have been retired, and which are permanent configuration that will never change. The code looks more complex than it actually is.&lt;/p&gt;

&lt;p&gt;There's also a subtler problem: old flags can interact with new flags. Two conditionals that were each simple on their own can combine into behaviour that nobody anticipated and that no test covers. Flag cleanup isn't just tidiness — it reduces the combinatorial surface of the system.&lt;/p&gt;

&lt;p&gt;The best time to remove a flag is shortly after the feature it was protecting has fully shipped and proven stable. The second best time is now. Keeping a habit of retiring flags when they're done means the codebase stays navigable, and the next engineer who needs to understand how the functionality works doesn't have to trace through a conditional that was only ever going one way.&lt;/p&gt;

&lt;p&gt;Sometimes the most useful thing you can ship in a week is less code than you started with.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>featureflags</category>
      <category>maintenance</category>
    </item>
  </channel>
</rss>
