A reusable deploy pipeline for Umbraco Cloud

Calculating...

A reusable deploy pipeline for Umbraco Cloud

TL;DR. We deploy every Umbraco Cloud project through one shared GitHub Actions workflow, so the deploy logic lives in a single place instead of a drifting copy in every repo. Each project keeps a small caller that pins a version and passes its own credentials. A deploy is triggered by pushing a version tag. It pulls any backoffice changes back into git first, so nothing gets silently overwritten, then ships the result to Cloud. Adding a new project takes a few minutes.

We run several Umbraco Cloud projects. Each one used to have its own GitHub Actions workflow, copied from the last project and adjusted. That does not scale. Every copy drifts, and a fix made in one repo never reaches the others. So we moved the pipeline into one place and gave each project a small workflow that calls it.

Contents

  1. The shared workflow and the caller
  2. What the shared workflow does
  3. What about conflicts?
  4. Deploying on a tag, and why checkout-ref matters
  5. The bug
  6. Versioning
  7. Reuse
  8. In short

The shared workflow and the caller

GitHub Actions has a feature called reusable workflows. It lets you write a workflow once and call it from other workflows, in the same way you write a function once and call it from different places in your code.

There are two parts to it, and the rest of this post uses both terms, so they are worth defining now.

The shared workflow is the reusable one. Ours is called umbraco-cloud-deploy.yml and it lives in a single repo, umbraco-cloud-workflows. It holds all the deployment logic. It is written to accept inputs so it can serve any project instead of being tied to one, and it states what it needs to run: a target environment, a project ID, and an API key.

The caller is the small workflow inside each project repo. Its only job is to call the shared workflow and pass in that project's own values. When this post says "the caller", it means this file. It is the one thing each project has to add, and it is short because all the real work sits in the shared workflow.

Here is a project's caller in full:

jobs:
  deploy:
    uses: initials-labs/umbraco-cloud-workflows/.github/workflows/umbraco-cloud-deploy.yml@1.1.1
    with:
      target-environment-alias: ${{ vars.TARGET_ENVIRONMENT_ALIAS }}
      checkout-ref: main
    secrets:
      project-id: ${{ secrets.PROJECT_ID }}
      api-key: ${{ secrets.UMBRACO_CLOUD_API_KEY }}

The uses: line is the call. It points at the shared workflow and pins a version. The with: block passes inputs. The secrets: block passes the project's credentials. Everything else, the sequence of steps and the logic, lives in the shared workflow and is identical for every project.

What the shared workflow does

Inside, the shared workflow runs one job made of three stages. Each stage is backed by a small PowerShell script that talks to the Umbraco Cloud API.

Sync pulls any changes that were made directly in the Cloud backoffice, for example an editor changing a document type, and commits them back into git so the repository stays the source of truth.

Pack zips the repository into an artifact and uploads it to Cloud as the thing to be deployed.

Deploy starts the deployment from that artifact, then polls Cloud until it reports success or failure, so a red or green result in GitHub reflects what actually happened on Cloud.

It also serialises deploys to the same environment, so two runs cannot target the same place at once and collide.

What about conflicts?

A fair question at this point. If I have changes committed in the repo and someone has also changed things in the backoffice, do I get a merge conflict?

The reassuring part is that sync does not merge. It asks Cloud for a patch of what changed in the backoffice, then applies that patch onto the branch. This is git apply, not git merge, so there is no three-way merge and it never writes conflict markers into your files. The patch either applies cleanly or it does not apply at all.

("Local changes" here means changes committed to the branch the pipeline checks out. Uncommitted changes on your own machine never reach the pipeline until you push them.)

That leaves two outcomes.

If the two sets of changes touch different files, or different parts of the same file, the patch applies cleanly on top of your committed changes. Both sets survive, sync commits the backoffice changes onto the branch, and the deploy ships the combined result. This is the common case, because code changes and backoffice changes usually land in different files.

If both sides changed the same lines of the same file, the patch does not apply, and the sync step stops the whole run before anything is packed or deployed. You do not get half-merged files or a clobbered environment. It fails cleanly and leaves everything as it was. In practice this happens when the same document type was edited both in code and in the backoffice, because both write to the same definition files.

When that happens you get a failed Sync step with git's rejection output, and the patch is saved as a run artifact so you can see exactly what Cloud was trying to bring down. To recover, pull the branch locally, reconcile the overlapping file by hand, commit, and redeploy.

This is deliberately safe rather than clever. The whole reason sync exists is so a deploy never silently overwrites backoffice changes with repo state. The cost is that it is stricter than a real merge. It will not quietly resolve an overlap, it stops and hands it to you. Whitespace-only differences are ignored, so those never trigger it, and keeping deploys frequent keeps the gap between repo and backoffice small enough that overlaps are rare.

Deploying on a tag, and why checkout-ref matters

Deploys run when you push a version tag, not on every push to main. That keeps releases deliberate. You bump the version, tag it, and push.

This creates one problem that is worth understanding, because it is not obvious.

When a workflow is triggered by a tag, GitHub checks out the code at that tag. A tag points at a single commit, not at a branch. Git calls this a detached HEAD. You have all the files, but you are not sitting on any branch.

That is fine for reading code, but the sync step does more than read. It commits the changes it pulled from Cloud and pushes them back to the repository. To push a commit, git needs to know which branch it belongs to. On a detached checkout there is no branch, so the push has nowhere to go and the step fails.

The fix is a single input. The caller passes checkout-ref: main, which tells the shared workflow to check out the main branch instead of the tag. The tag still triggers the run, but the working copy is now a real branch. The sync step can commit its changes and push them to main as normal.

One consequence to be aware of. Because it checks out main rather than the exact tagged commit, the deploy uses the current tip of main. In practice we tag main once the code we want to release is already on it, so the two match. If you tagged an older commit, the deploy would still use main.

The bug

Two bits of background first, because the problem does not make sense without them.

A "patch" is just a text file that describes changes to other files: in this file, remove these lines, add those. Git can take a patch and apply it to bring files up to date. If the files do not already look the way the patch expects, git refuses and reports an error.

The second bit is where changes come from. Content and structure can change in two places: in your code, or directly in the Umbraco Cloud backoffice when an editor changes something. The sync step exists to pull those backoffice changes back into git, so code and Cloud stay in agreement.

Here is the sequence that went wrong.

  1. Sync runs. It asks Cloud what has changed since the last deploy. Cloud replies with a patch. The pipeline saves that patch to a temporary file, patch/git-patch.diff, and applies it. This part is correct.
  2. Pack runs. It zips the whole project folder to send to Cloud. Nothing told it to leave out that temporary patch file, so the file went into the zip and was deployed to Cloud with everything else. It was never meant to leave the pipeline, but now Cloud had a copy of it.
  3. On the next deploy, sync asks again what has changed. This time Cloud's answer included patch/git-patch.diff, because that file was now sitting on Cloud. So the patch Cloud handed back tried to modify patch/git-patch.diff. But the pipeline was downloading the new patch to that same filename at the same time. The file git was told to change no longer matched what the patch expected, so git refused with removal patch leaves file contents, and the deploy failed.

flowchart TD subgraph first["A deploy that shipped the file"] A["Sync writes Cloud's changes to patch/git-patch.diff"] --> B["Pack zips the whole folder,<br/>patch/git-patch.diff included"] B --> C["Deploy sends the artifact to Cloud"] C --> D["Cloud now stores patch/git-patch.diff"] end subgraph next["Every deploy after that"] E["Sync asks Cloud what changed"] --> F["Cloud's patch references patch/git-patch.diff"] F --> G["But sync is writing the new patch<br/>to that same file at the same time"] G --> H["git apply fails:<br/>removal patch leaves file contents"] end D --> E

The important part is that nothing external broke it. Each deploy shipped the file that made the next deploy fail. That is what "the pipeline was breaking itself" means. Once it started, it could not recover on its own.

The fix was to tell the pack step to ignore the temporary patch/ folder, so it never goes into the zip and never reaches Cloud again. That is the one line added to cloud.zipignore. We then ran one clean deploy to remove the copy already sitting on Cloud.

The lesson is worth keeping. A .gitignore decides what git tracks. It has no effect on what a zip command picks up. They are different tools with different rules. A temporary file that a build step creates should never end up in the package you deploy.

Versioning

The shared workflow gets updated over time as bugs are fixed and features are added. Each version is marked with a tag, such as 1.1.0 or 1.1.1.

Look again at the end of the caller's uses: line:

...umbraco-cloud-deploy.yml@1.1.1

The @1.1.1 says which version to use. Locking to a specific version like this is called pinning. Because a project is pinned, its deploys behave the same way every time. They do not suddenly change because someone updated the shared workflow. You move to a newer version only when you deliberately change that number. That is the point. No surprises.

There is also a v1 tag that always points at the newest 1.x version. A caller that uses @v1 instead of @1.1.1 picks up new releases automatically. That is the trade-off. @1.1.1 is fixed and predictable. @v1 is convenient but can change underneath you.

One thing that catches people out. These version numbers belong to the shared workflow repo. Each project repo also has its own tags, like 1.0.2, but those are just the triggers that start that project's deploys. Same style of number, different job, different repo.

Reuse

This is what the shared setup buys you. Because the logic is not copied into each project, starting a new one is quick. You add the small caller file, a cloud.gitignore, two secrets and a variable, and grant write permission.

To make even the caller automatic, it is registered as an organisation workflow template. Any repo can add it from the New Workflow screen in two clicks, without copying anything by hand.

In short

One pipeline, versioned in one place, used across every project. Fixes land once and reach everything.


Claude wrote it. Owain made sure it was actually true.