Code Synchronisation: Customer Copies of the Code Repository¶
Every platform runs the code of the platform from a repository in the organisation that runs the pipelines: an Azure DevOps organisation or a GitHub organisation. That repository is a copy (a mirror) of the code repository that the product team maintains. This page describes how the copy is kept up to date, how it is protected against local changes, and what to check when it fails. It also covers the second kind of synchronisation in the code: keeping Azure DevOps pipelines and GitHub workflows identical.
Who runs the synchronisation depends on your operating model. In the managed model XMMS runs it and the customer provides the access. In the self-managed model, agree with the owner of the code how new versions reach your copy. The mechanism below is the one XMMS uses.
Why it exists¶
Before the synchronisation was automated:
- every update of the customer copy was a manual pull request, and
- the customer copy could be changed locally, which created more than one source of truth.
Both caused drift and surprises at upgrade time. The automated, one-way synchronisation fixes both. It makes the code of the customer copy identical to the source, and, together with the lock-down below, prevents local edits that the next synchronisation would overwrite.
How it works¶
flowchart LR
src["Source code repository<br/>(release branches, tags)"] -- "push / tag" --> trig["Trigger"]
trig --> sync["Sync pipeline"]
sync -- "service principal" --> ado["Customer copy<br/>(Azure DevOps)"]
sync -- "GitHub App" --> gh["Customer copy<br/>(GitHub)"]
ado --> cfg["Configuration repository<br/>pins a version"]
gh --> cfg
- A sync pipeline pushes the branches and tags of the source repository to every customer copy. It can be started by hand too.
- The trigger is the release. In the current set-up a GitHub workflow in the source repository (
trigger-sync.yaml) runs on pushes tomain, torelease/**and onv*tags, and hands over to the sync dispatcher (Releasing). The first version of the synchronisation was an Azure DevOps pipeline that ran on new tags that start withv. - A synchronisation does not upgrade anybody. The configuration repository pins a version (Versioning), and moving the pin is a deliberate pull request. The copy only makes the new version available.
Prepare a target¶
What you need depends on where the customer copy lives. Both targets need somebody with owner rights in the target organisation, so plan for the customer to do this part.
Azure DevOps target¶
| Step | Where | Who |
|---|---|---|
| 1. Add the sync service principal to the target Azure DevOps organisation | Organisation settings | An organisation owner of the target |
| 2. Add it to the project that holds the copy, at least as a project reader | Project settings | A project administrator |
| 3. Give it the permissions on the repository that it needs to push branches and tags | Repository security | A project administrator |
| 4. Create a service connection for this target in the project that runs the sync pipeline | Project settings of the sync project | The platform team |
In the first set-up the sync used the production deployment service principal of the platform. A dedicated service principal limits the damage if its secret leaks. Check the permission set against the sync template before you rely on it.
GitHub target¶
Authentication uses a GitHub App installed in the target organisation. A GitHub organisation owner does the steps below.
-
Create the app in the organisation (
https://<github host>/organizations/<org>/settings/apps/new):Setting Value Name and description A name such as repo-sync, with a description that says it synchronises the repositories provided by the platform teamWebhook Not active Repository permissions Contents: read and write. Workflows: read and write Where it can be installed Only on this account -
Note the App ID, then generate and download a private key (
.pem). - Install the app in the organisation, with Only select repositories, and select only the code repositories that are synchronised. Note the installation ID.
- In the sync project, store the private key as a secret variable in the variable group of the sync pipeline, for
example
<customer>_GH_privatekey. Mark it secret.
The pipeline signs a JSON Web Token with the private key and the App ID, exchanges it for an installation access token, and uses that token to push. No user account is involved.
Lock down the copy¶
Close the copy so that nobody changes it by accident. A change in the copy would be overwritten by the next synchronisation, or it would stop the synchronisation.
Azure DevOps target. Set every permission of every other user and group on the repository to not set. That way the repository can be read, but no change is possible without a manual intervention.
GitHub target. Disable GitHub Actions on the repository and protect all branches with a ruleset that only the app may bypass. Example with the GitHub CLI (PowerShell 7). Fill in the placeholders, and run it once per repository:
$sync_app_id = "<sync app id>"
$org = "<github organisation>"
$repos = @('<code repository>', '<second code repository, if any>')
gh auth login
$ruleset_name = "RO-enforcement"
foreach ($repo in $repos) {
Write-Host "Configuring $repo"
gh api --method PUT repos/$org/$repo/actions/permissions -F "enabled=false"
$actions_enabled = gh api --method GET repos/$org/$repo/actions/permissions --jq .enabled
Write-Host " - Actions enabled: $actions_enabled. Should be disabled."
$existing_ruleset = gh api /repos/$org/$repo/rulesets --jq ".[] | select(.name == `"$ruleset_name`")"
if (-not $existing_ruleset) {
$body = @"
{
"name": "$ruleset_name",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["~ALL"], "exclude": [] }
},
"rules": [
{"type": "deletion"},
{"type": "non_fast_forward"},
{"type": "creation"},
{"type": "update"}
],
"bypass_actors": [
{ "actor_id": $sync_app_id, "actor_type": "Integration", "bypass_mode": "always" }
]
}
"@
$ruleset_created = $body | gh api --method POST /repos/$org/$repo/rulesets --input -
$ruleset_created = $ruleset_created | ConvertFrom-Json
Write-Host " - Ruleset $($ruleset_created.name) has been created"
}
else {
Write-Host " - Ruleset $ruleset_name already exists"
}
}
Customer-specific changes do not belong in the copy
A locked copy forces the right habit: all customer-specific values go in the configuration repository, and a change to the code goes through the product team. If you need a fix before the next release, ask for a patch release (Patch releases).
The sync pipeline¶
The pipeline has two files: an entry file that lists the targets, and a template that does the push. The template loops over
the targets (an each loop). The entry file triggers on tags that start with v and lists one entry per target:
trigger:
tags:
include:
- v*
pool:
vmImage: "ubuntu-latest"
stages:
- stage: push_remotes
jobs:
- template: sync-template.yaml
parameters:
sourceRepo: ${{ variables.sourceRepo }}
remotes:
- name: "<customer-a>" # Azure DevOps target
remoteUrl: "<host>/<organisation>/<project>/_git/<repository>"
serviceConnection: "<service connection>"
- name: "<customer-b>" # GitHub target
remoteUrl: "<github host>/<organisation>/<repository>"
githubAppPrivateKey: $(<customer-b>_GH_privatekey)
githubAppId: "<app id>"
githubInstallationId: "<installation id>"
Add a target by adding an entry and, for GitHub, the secret variable. Separate sync pipelines exist for the platform code and for the older spoke canvas code, with the same structure.
After a release¶
- Check that the sync pipeline ran successfully for every target.
- Open a few customer copies and check that the new tag exists and that the commit history of the branch is updated.
When a synchronisation fails¶
| Check | Why |
|---|---|
| Is the credential still valid? | The sync service principal has a secret, and the GitHub App has a private key. An expired secret is a common cause (Secret watcher) |
| Is the service connection or the secret variable for this target present? | A new target needs both the pipeline entry and the credentials |
| Does the identity still have access to the target organisation, project and repository? | Somebody may have removed the service principal, or changed permissions on the lock-down |
| Is the customer copy still locked and unchanged? | A local change, or a rule that blocks the sync identity, stops the push. Fix it by removing the change in the copy, not by weakening the lock-down for everybody |
Keeping Azure DevOps and GitHub pipelines identical¶
The code repository holds two pipeline systems that must stay functionally identical: Azure DevOps pipelines in
.azure-devops/pipelines/ and GitHub Actions workflows in .github/workflows/. Both deploy to the same infrastructure, so a
difference in deployment logic, layer order, job dependencies or configuration handling causes drift and failures. The
rule: a change in one system is made in the other in the same commit.
| Azure DevOps | GitHub Actions |
|---|---|
Entry pipelines (hub.yaml, hub-destroy.yaml, spoke.yaml, infrastructure.yaml, container.yaml, test.yaml, update-spn.yaml) |
The same file names in .github/workflows/ |
Stage templates in templates/ (stage-mcp-hub.yaml, stage-mcp-spoke.yaml, ...) |
stage-*.yaml reusable workflows next to the entry workflows |
Job templates in templates/ (job-mcp-hub.yaml, job-mcp-spoke.yaml, job-spn.yaml) |
job-*.yaml reusable workflows |
GitHub Actions does not allow workflow files in subfolders of .github/workflows, so the templates of Azure DevOps become
reusable workflows that the entry workflows call, named by prefix (stage- and job-).
| Azure DevOps | GitHub Actions | |
|---|---|---|
| Azure credentials | Service connections, set through pipeline variables | OIDC, resolved from mcp.json |
| Inputs | parameters (compile time ${{ parameters.name }}, run time $(variable)) |
workflow_call inputs (${{ inputs.name }}), job outputs ${{ needs.job.outputs.name }} |
| Structure | Stages, jobs, steps | Jobs and steps (no stages) |
The deployment order that both must keep (see also Deployment layers):
- level 1: foundation (primary region), then connectivity (primary region);
- level 2: foundation, then connectivity, for the other regions;
- level 3: firewall, identity and management in parallel, then postdeployment.
Change checklist:
- [ ] Identify what changes: layer order, job logic, configuration handling.
- [ ] Make the change in the primary system, then translate it to the other, allowing for the differences above.
- [ ] Update the comments in both files.
- [ ] Test both in a non-production environment, with
whatIf(plan only), and compare the outputs: they must match. - [ ] Commit both changes together.
If you are unsure whether a change needs a counterpart, ask first. Pipelines that diverge cause failed deployments, drift between environments and state problems. A script that compares layer order and dependencies automatically does not exist yet.