Fail closed
A flag called --allow-live, a safety net that ate its own backups, and CI that refuses to guess.
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 a Friday, well.
The CLI does try to warn you. There is 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 is not a process. It’s a speed bump between you and a mistake, and I’ve watched enough people (me, mostly) blow through speed bumps.
So over the spring I moved all of it into GitHub Actions. Not a heroic project, maybe two weeks of evenings and Friday afternoons total, and the interesting part wasn’t the automation. It was 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: 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, not fifteen copies
The first decision was the only one I’d call architectural, and I think it’s 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:
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 }}
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, and I’ll admit I’ve thought about pinning tags and then not done it.
The preview workflow was the easy win and the one clients noticed. Open a PR, 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, 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.
The backup, and the three times I got it wrong
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 someone who doesn’t need to know what git is.
Fine. Except Shopify caps how many themes a store can have, so you can’t just accumulate backups forever, and something has to prune. This is where I spent most of my actual time, and 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 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 '#')
Two problems, one boring and one sneaky. The boring 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 --json, so: --json and jq, sorted properly by 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, and 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 doing that is genuinely worse than no safety net, because you only find out on the day you need it.
A backup system that deletes your backups is worse than no backup system, because you find out on the day you need it.
I could have detected the plan. There’s probably an API call. I could have made it a workflow input and let each repo declare 20 or 100, and been wrong later when somebody upgrades.
What I did instead was delete 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.
KEEP=$(( MAX_BACKUPS - 1 )) # leave room for the one we're about to make
DELETE_IDS=$(echo "$THEMES_JSON" | jq -r --argjson keep "$KEEP" \
'[.[] | select(.name | test("CL Backup"))]
| sort_by(.created_at) | reverse | .[$keep:] | .[].id')
Three lines and 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 estimate. It was refusing to estimate.
I feel like I re-learn this one about twice a year and it never sticks.
Refusing to guess, on purpose this time
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 in a way 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 using the store and theme id from that file. Otherwise, live. The 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’s not a new source of truth, it’s the one people were already maintaining.
The rest of the design is just paranoia, and I’d defend all of it. Placeholder values (STORE_URL, REDESIGN_THEME_ID) abort the run. Empty values abort the run. And the one I’m proudest of:
There’s also a concurrency group so two merges can’t deploy over each other, and a Slack message on both success and failure with the commit, the author, a link to the run, and which mode it took. That last field is only there because during the migration I wanted to see, in the channel, that redesign repos were actually taking the redesign path. It stayed, and now it’s the thing I check first when someone asks what happened.
What generalizes
I think the summary is that basically 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.
What changed is that the scary command isn’t a command anymore. It’s a merge, and 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. It’s the difference between “we’re careful” and “it’s not possible to be careless here,” and only one of those survives a Friday afternoon.
Take the specifics with a grain of salt, obviously, 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 arithmetic about something it can’t actually observe, 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 it has in hand, and let it fail closed on everything else.