← All writing
Craft · · 9 min

Keep the last three

A flag called --allow-live, a safety net that ate its own backups, and a rule too short to be wrong.

Shopify Tooling

For a long time the way a Shopify theme change went live at work was that somebody ran shopify theme push --live from their laptop. That was the deploy. Whatever happened to be sitting in your working directory at that moment became somebody’s storefront, and if you were on the wrong branch, or you’d stashed something, or you’d been poking at a section file at 4:50 on your way out the door, well.

The CLI does try to warn you. There’s a flag called --allow-live and you have to pass it explicitly, which tells you roughly everything about how confident Shopify is that you meant to do this. But a confirmation prompt isn’t a process. It’s a speed bump between you and a mistake. I’ve watched enough people (me, mostly!) go straight over a speed bump.

I made this exact argument to myself in 2019 and then spent the next several years watching it not generalize, because getting my own deploys off my own machine is a weekend and getting 15 client repos off 15 laptops is a policy. So over the spring I moved all of it into GitHub Actions. Maybe two weeks of evenings and Friday afternoons total, and the automation isn’t the interesting part. The interesting part is how many times I had to be wrong about theme limits before I gave up on being smart.

Worth scoping this before I go further, since it’s about themes. The two Hydrogen projects we picked up this year are Node apps that deploy like any other Node app and they have none of the problem below. That’s a quieter argument for the headless stack than any of the performance ones, and not one I expected to be making.

One repo instead of fifteen copies

The first decision was the only one I’d call architectural, and probably the one that actually mattered.

Every Shopify project we have needs the same CI. Push a preview theme on PRs, back up and deploy on merge, tell Slack. The obvious move is to write that once and paste the yml into every repo. The obvious move is also how you end up with 15 slightly different deploy pipelines, three of which have a fix nobody backported and one of which is still on Node 16.

GitHub has workflow_call for exactly this. You put the real workflow in one repo and every other repo calls it by reference, passing secrets through. So cadencelabs/shopify-workflows has no application code, no build step, no dependencies, no tests. It’s four yml files and a CLAUDE.md. The caller side in a client repo is about this long:

.github/workflows/deploy.yml
jobs:
  deploy:
    uses: cadencelabs/shopify-workflows/.github/workflows/deploy.yml@main
    secrets:
      SHOPIFY_THEME_ACCESS_PASSWORD: ${{ secrets.SHOPIFY_THEME_ACCESS_PASSWORD }}
      SHOPIFY_STORE_URL: ${{ secrets.SHOPIFY_STORE_URL }}
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
YAML

That’s the whole integration. Fix a bug in the shared repo and every project gets it on its next run, which is either wonderful or terrifying depending on your relationship with @main. I’ll admit I’ve thought about pinning tags and then not done it.

The credentials don’t move, which I care about more than I would have a year ago. Each client repo keeps its own and hands them in at call time, so the shared repo never holds one and there’s nothing in it worth stealing. After what I found sitting in a tracked .env back in July I’m a lot less relaxed about where those things quietly pile up.

The preview workflow was the easy win and the one clients noticed. Open a PR and it derives a theme name from the branch (stripping the feature/ prefix, because nobody wants to click a preview link called feature/pdp-fixes), pushes an unpublished theme, and reuses the existing one if the name already matches so you’re not spawning a new theme per commit. Close the PR and it deletes the theme. Reviewers get a real URL on the real store instead of a screenshot, and the store doesn’t slowly fill up with dead branches.

Wrong, wrong, and then worse

Before any live deploy the workflow pulls the current live theme down and pushes it back up as an unpublished copy named CL Backup - 2025-11-03 - Live Theme. So if a deploy goes sideways there’s a known-good theme sitting right there in the admin, and recovery is a click by somebody who doesn’t need to know what git is.

Fine. Except Shopify caps how many themes a store can have, so you can’t accumulate backups forever and something has to prune. This is where most of my actual time went. It’s the part I’d want to read, so.

The first pass counted themes by parsing the human-readable output of shopify theme list and pulled the IDs out with awk.

THEME_LIST=$(shopify theme list)
THEME_COUNT=$(echo "$THEME_LIST" | wc -l)
...
echo "$THEME_LIST" | grep "CL Backup" | while read -r line; do
  THEME_ID=$(echo "$line" | awk '{print $1}' | tr -d '#')
Bash

Two problems, one obvious and one sneaky. The obvious one is that wc -l on pretty-printed CLI output counts headers and blank lines, so the count was never the count. The sneaky one is that piping into while runs the loop body in a subshell, so the decrement inside it evaporates the moment the loop ends. The script was carefully maintaining a running total that the outer shell never saw.

The CLI has a --json flag, so the second pass was that plus jq, sorted properly on created_at, deleting oldest first. That fixed the parsing.

Then it started deleting too much. Turns out development themes (the ephemeral ones shopify theme dev spins up) don’t count toward the store’s limit. I was counting them. So the store looked full when it wasn’t, and the cleanup would helpfully free up space nobody needed. One select(.role != "development") and done.

And then it got worse, which is the good part. On a Shopify Plus store the limit isn’t 20, it’s 100. My threshold was hardcoded at 20. So on Plus the count came back at something like 30, and the workflow concluded the store was ten themes over a cliff it wasn’t anywhere near, panicked, and deleted every backup except the newest one. A safety net that does that is worse than no safety net, because you only find out on the day you need it.

I could have detected the plan. There’s probably an API call for it. I could have made it a workflow input and let each repo declare 20 or 100, and been wrong later on when somebody upgrades.

Instead I deleted the whole idea. The workflow doesn’t know the limit, doesn’t ask, doesn’t care. It keeps the three most recent CL Backup themes and prunes everything past that, on every store, forever.

# leave room for the one we're about to make
KEEP=$(( MAX_BACKUPS - 1 ))
DELETE_IDS=$(echo "$THEMES_JSON" | jq -r --argjson keep "$KEEP" \
  '[.[] | select(.name | test("CL Backup"))]
   | sort_by(.created_at) | reverse | .[$keep:] | .[].id')
Bash

It cannot be wrong about a plan tier, because it never has an opinion about one. The store’s theme limit is a fact about somebody’s billing relationship with Shopify, and my CI had no business modeling it. Every bug in that sequence came from trying to reason about a number I couldn’t see from inside a GitHub runner, and the fix wasn’t a better guess. It was not guessing.

I feel like I re-learn this one about twice a year and it never sticks lol

Which theme is this even going to?

The last piece was consolidation. We’d grown two deploy workflows. push-live.yml for normal projects, and push-to-theme.yml for redesign projects, where the new site lives on an unpublished theme and the old one is still taking orders. Two workflows, two setups, and exactly one way for a repo to be configured wrong that publishes an unfinished redesign over a live storefront.

So deploy.yml is one workflow that figures out its own target. It reads the calling repo’s shopify.theme.toml, and if there’s an uncommented, fully populated [environments.redesign] block it takes the redesign path, using the store and theme id from that file. Otherwise, live. That config already existed and developers already keep it accurate because their local shopify theme dev depends on it, which is the part I like. It isn’t a new source of truth, it’s the one people were already maintaining.

The rest is paranoia and I’d defend all of it. Placeholder values (STORE_URL, REDESIGN_THEME_ID) stop the run. Empty values stop the run. A concurrency group so two merges can’t deploy over each other.

And the one I’m proudest of is petty. The Slack message says which mode the run took, live or redesign, next to the commit and the author and a link to the run. That field only exists because during the migration I wanted to sit and watch the channel and confirm that redesign repos were really taking the redesign path. It stayed, and now it’s the first thing I look at when somebody asks what happened.

Obligatory YMMV

None of this was about speed. A deploy takes about the same wall-clock time it always did, and if you’d asked me to justify two weeks of evenings on throughput grounds I couldn’t have.

The scary command isn’t a command anymore. It’s a merge. There’s a backup, and there’s a record in a channel of who did it and when, and the pipeline would rather stop than guess. That’s the difference between “we’re careful” and “you can’t be careless here,” and only one of those survives the end of a long week.

Take the specifics with a grain of salt, since this is shaped around one agency’s projects and one CLI’s quirks. But the shape of the mistake generalizes, I think. If your automation is doing math about something it can’t actually see, it’s going to be wrong eventually, and probably in the direction of confidently deleting something. Give it a rule it can enforce with what’s already in front of it, and where you can’t do that, have it stop and go find a person instead of taking its best guess.

Read similar posts
11 min

Who owns this file?

Our template for new Shopify projects contains no theme code at all, and the most useful thing in it turned out to be the part that says which files we own, which ones belong to the client, and which ones should never have been copied in.

10 min

It's never the prompt

The prompting turns out to be the least interesting part of my Claude Code setup, and most of what's actually doing the work is a handful of files I wrote once and then stopped thinking about.