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.
The line in our new deploy workflow that actually changes a client’s storefront is shopify theme push --live --allow-live. I looked at that second flag for a while before I committed it.
--allow-live is how you tell the Shopify CLI that yes, you mean the published theme. In a terminal it stops and asks first. On a CI runner there’s nobody to ask, so it won’t touch the live theme at all unless you pass the flag. That 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. In a yml file the speed bump is a string that’s always there. So whatever made this safe was going to have to live somewhere else.
Since March I’ve been moving our Shopify deploys off DeployBot and into GitHub Actions. DeployBot did what it said it would. You pointed it at a repo and a theme and it put the repo’s files on that theme. (The Magento clients never even had that much. A deploy there meant somebody SSHing into the production box and running the commands by hand. None of this is about them.)
The way we had it set up, though, DeployBot did that one job and nothing around it. There was no preview theme for a pull request. It could roll a deploy back, but only to something that was in git. A good chunk of any live theme is whatever the client changed in the theme editor last week.
And the setup lived in a web dashboard, one project at a time, where nobody reviewing a pull request would ever see it. The new setup covers all three.
It took maybe two weeks of actual work spread over the spring. The automation isn’t the interesting part. Theme limits are. I was wrong about them three times before I gave up on being smart.
One repo instead of 15 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, 3 of which have a fix nobody backported and 1 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 the shared repo, 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:
jobs:
deploy:
uses: our-org/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 }}
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’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. The shared repo never holds one, so there’s nothing in it worth stealing. After what I found sitting in a tracked .env last 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. Reviewers get a real URL on the real store instead of a screenshot.
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) and pushes an unpublished theme by that name. If there’s already a theme by that name it reuses that one, so you’re not spawning a new theme per commit. Close the PR and it deletes the theme so 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 CI Backup - 2026-04-02 - Live Theme. So if a deploy goes sideways there’s a known-good theme sitting right there in the admin. 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, which a coworker wrote, 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 "CI Backup" | while read -r line; do
THEME_ID=$(echo "$line" | awk '{print $1}' | tr -d '#')
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 assiduously maintaining a running total that the outer shell never saw.
The CLI has a --json flag. So the second pass was that plus jq, 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. The workflow concluded the store was 10 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 CI 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("CI Backup"))]
| sort_by(.name) | reverse | .[$keep:] | .[].id')
It can’t 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. 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. The fix wasn’t a better guess. It was not guessing. I feel like I relearn this one about twice a year and it never sticks 🥲
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. If there’s an uncommented, fully populated [environments.redesign] block, it takes the redesign path with the store and theme ID from that file. Otherwise, live.
That config already existed and developers keep it accurate because their local shopify theme dev depends on it. That’s the part I like. It isn’t a new source of truth, just 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. If you’d asked me to justify two weeks of work on throughput grounds, I couldn’t have.
The deploy itself is the same dull step it was under DeployBot. Everything around it changed. There’s a preview somebody looked at before the merge and a backup of whatever the client had live. And the pipeline would rather stop than guess.
That’s the difference between “we’re careful” and “you can’t be careless here.” 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, probably in the direction of confidently deleting something. I’d give it a rule it can enforce with what’s already in front of it. And where there’s no such rule, I’d have it stop and go find a person instead of taking its best guess.