Martin Rylko
  • Services
  • Blog
  • About
  • Contact
  • Get in Touch
Martin Rylko

Senior Cloud Architect & DevOps Engineer. Specializing in Microsoft Azure, IaC, Cloud Security and AI.

Navigation

  • Services
  • Blog
  • About
  • Contact

Collaboration

Looking for an experienced architect for your Azure project? Get in touch.

rylko@cloudmasters.cz

© 2026 Martin Rylko. All rights reserved.

Built in the cloud. Deployed via Azure Static Web Apps.

Home/Blog/Bicep Shared Modules: Skip-if-Exists Pattern Instead of --force
All articlesČíst česky

Bicep Shared Modules: Skip-if-Exists Pattern Instead of --force

4/15/2026 4 min
#Bicep#Azure#DevOps#IaC#CI/CD

Bicep Shared Modules: Skip-if-Exists Pattern Instead of --force

At Christie's we have a well-documented setup – the platform team runs 76 shared Bicep modules in an Azure Container Registry and dozens of workload repositories consume them through br:registry/module:tag references. Versions are pinned, semver disciplined, schemas stable. Until the day Russell, the platform team lead, showed me a recent incident.

The Storage module cmn-storage:1.4.2 got overwritten as part of a hotfix. A consumer in the retail subscription saw propertyName Foo of ResourceType Bar is not allowed on the next az deployment. The pinned 1.4.2 had changed under their feet because the publish pipeline used --force.

That event spawned ticket DO-429: replace --force with a skip-if-exists pattern and enforce semver bumps in PR validation. Here is the playbook.

Why --force Existed in the First Place

The historical reason was convenience. The CI pipeline published on every merge to main, and if someone forgot to bump the version, the build failed. The team got used to writing --force as a quick fix instead of fixing semver hygiene. After 18 months it became the default behavior in the main pipeline.

# Before – publish-modules.yml (wrong)
- task: AzureCLI@2
  inputs:
    azureSubscription: $(azureServiceConnection)
    scriptType: bash
    inlineScript: |
      for module in modules/*/; do
        name=$(basename "$module")
        az bicep publish \
          --file "$module/main.bicep" \
          --target "br:$(acrName).azurecr.io/bicep/$name:$(version)" \
          --force   # <-- the problem
      done

Three-bullet summary of the problem: silently overwrites published versions, no audit trail, no checkpoint where the version was supposed to bump.

The Skip-if-Exists Pattern

The principle: check whether the version already exists. If yes, log and skip. If no, publish.

#!/usr/bin/env bash
set -euo pipefail
 
ACR_NAME="$1"
MODULE_NAME="$2"
VERSION="$3"
 
# Check whether the tag exists
if az acr repository show-tags \
     --name "$ACR_NAME" \
     --repository "bicep/$MODULE_NAME" \
     --query "[?@=='$VERSION'] | [0]" -o tsv | grep -q "$VERSION"; then
  echo "::notice::Module $MODULE_NAME:$VERSION already exists, skipping."
  exit 0
fi
 
# Publish (without --force)
az bicep publish \
  --file "modules/$MODULE_NAME/main.bicep" \
  --target "br:$ACR_NAME.azurecr.io/bicep/$MODULE_NAME:$VERSION"
 
echo "::notice::Published $MODULE_NAME:$VERSION"

Three properties that make this script enterprise-ready:

  1. set -euo pipefail – any error kills the pipeline, no silent failures
  2. Skip logged via GitHub ::notice:: – the job summary shows exactly what was published and what was skipped
  3. No --force – if the version exists, that is the end; an older version cannot be overwritten

PR Validation: Enforce Version Bumps

Skip-if-exists addresses the production risk. But you still risk a developer changing a module, forgetting to bump the version, and the merge passing – production consumers with pinned versions will never see the change.

The fix: a PR check that watches the diff and requires a semver bump for every changed module.

# .github/workflows/pr-validate-bicep.yml
name: Validate Bicep module changes
on:
  pull_request:
    paths:
      - 'modules/**'
 
jobs:
  semver-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # needed for diff against main
 
      - name: Check semver bump for changed modules
        run: |
          changed_modules=$(git diff --name-only origin/main...HEAD modules/ | awk -F'/' '{print $2}' | sort -u)
          fail=0
          for module in $changed_modules; do
            metadata_file="modules/$module/metadata.json"
            if ! git diff origin/main...HEAD -- "$metadata_file" | grep -q '"version"'; then
              echo "::error::Module $module changed but version in $metadata_file not bumped."
              fail=1
            fi
          done
          exit $fail

Combining skip-if-exists at publish time with a PR check on the version means a change cannot be deployed with the old version by accident.

Consumer Side: How to Pin a Version Properly

A consumer repository pins a specific version:

module storage 'br:cmnregistry.azurecr.io/bicep/cmn-storage:1.4.3' = {
  name: 'storageDeployment'
  params: {
    storageAccountName: name
    location: location
    sku: 'Standard_LRS'
  }
}

What not to do:

  • Do not use the latest tag – it physically exists in ACR but defeats semver
  • Do not use ranges – the Bicep registry has no range syntax (^1.4.0); always a concrete tag
  • Do not publish modules from feature branches into the production registry – keep a separate bicep-dev/ repository inside ACR

Renovate / Dependabot for Bicep Modules

To keep consumer repositories current I recommend Renovate, which understands Bicep registry references natively since v39:

// renovate.json
{
  "extends": ["config:recommended"],
  "bicep": { "enabled": true },
  "packageRules": [
    {
      "matchManagers": ["bicep"],
      "matchUpdateTypes": ["minor", "patch"],
      "automerge": true,
      "automergeType": "pr"
    },
    {
      "matchManagers": ["bicep"],
      "matchUpdateTypes": ["major"],
      "labels": ["bicep-major-bump", "needs-review"]
    }
  ]
}

Patch and minor bumps merge automatically (the semver contract says they are compatible). Major bumps go to review – the workflow labels and notifies the cloud platform team.

Audit: Who Overwrote a Module and When

Even with the break-glass procedure we want to know when --force was used. At Christie's we wire ACR Activity Log into a Log Analytics alert:

resource forcePublishAlert 'Microsoft.Insights/scheduledQueryRules@2023-03-15-preview' = {
  name: 'alert-bicep-module-overwrite'
  location: location
  properties: {
    severity: 1
    enabled: true
    evaluationFrequency: 'PT15M'
    windowSize: 'PT15M'
    scopes: [logAnalyticsWorkspaceId]
    criteria: {
      allOf: [
        {
          query: '''
            ContainerRegistryRepositoryEvents
            | where Repository startswith "bicep/"
            | where OperationName == "Push"
            | summarize PushCount = count() by Repository, Tag = MediaType, Identity
            | where PushCount > 1
          '''
          timeAggregation: 'Count'
          operator: 'GreaterThan'
          threshold: 0
        }
      ]
    }
    actions: {
      actionGroups: [securityActionGroupId]
    }
  }
}

If a second push arrives at the same tag inside a 15-minute window, the security team is alerted. It is usually a legitimate break-glass operation, but occasionally it catches a pipeline misconfiguration.

Rollout: How to Replace --force in an Existing Environment

At Christie's we did this in four steps:

StepWeekRisk
Implement skip-if-exists, keep --force as a fallback flag1None – behavior unchanged
Run both modes in parallel, monitor the skip rate2–3None – data collection only
Communicate the change to all teams, give 1 week's notice4None
Remove the --force flag, enforce the semver PR check5Low – occasional PR fails, dev fixes

After five weeks we had zero overwrite incidents and no team complained about the publish process being slower (skipping is faster than publishing).

Conclusion

--force on az bicep publish is a landmine in a multi-team environment. The skip-if-exists pattern, semver PR validation, and audit alerting defuse it in a five-week rollout. The cost is zero – just discipline and 50 lines of Bash.

If you are dealing with a similar situation around shared Bicep or Terraform modules at enterprise scale, check out our cloud architecture services or reach out for a review of your IaC supply chain.

Tags:#Bicep#Azure#DevOps#IaC#CI/CD
LinkedInX / Twitter

About the author

Martin Rylko

Martin Rylko

Senior Cloud Architect & DevOps Engineer

14+ years in IT – from on-premises datacenters and Hyper-V clustering to cloud infrastructure on Microsoft Azure. I specialize in Landing Zones, IaC automation, Kubernetes and security compliance.

Email LinkedInFull profile

Frequently Asked Questions

Why is --force a problem when publishing Bicep modules?▾
The --force flag on az bicep publish overwrites an existing module version in ACR without warning. In a small environment that is a minor annoyance. In an enterprise environment where 50 consumers have pinned versions, it means someone silently broke another team's production. A real incident I handled at Christie's: a hotfix to a shared Storage module overwrote v1.4.2 and every consumer pinned to 1.4.2 started drifting on the next deployment.
How does semver apply to Bicep module publishing?▾
Strictly. Patch (1.4.2 → 1.4.3) for bug fixes with no API changes. Minor (1.4 → 1.5) for backward-compatible new inputs. Major (1.x → 2.0) for breaking changes in the parameter schema, required values, or outputs. Consumers then pin a specific version in main.bicep (br:registry/module:1.4.3) and never get surprise behaviour on the next publish.
What if I genuinely need to overwrite a published version?▾
Have a procedure, but not in the pipeline. At Christie's we have a break-glass process: a Jira request approved by the Cloud Platform lead, temporary AcrPush role granted to a specific service principal, manual az bicep publish --force recorded in the audit log, role revoked immediately after. It gets used about once a year on average. If you use it more often, you are not treating the symptom – you have a broken versioning process.
Does the skip-if-exists pattern work in both Azure DevOps and GitHub Actions?▾
Yes, it is fully CLI-driven (az acr repository show + a bash exit code). In GitHub Actions it is five lines in a workflow; in Azure DevOps it is the same in a YAML pipeline. GitHub Actions has a visible advantage because the job summary renders a skip vs publish table nicely – in ADO you have to write the result into the log or an extension output.

You might also like

Bicep CI/CD: GitHub Actions Pipeline for Azure

Build a production Bicep deployment pipeline with GitHub Actions. Covers what-if previews, environment approvals, OIDC authentication, and rollback strategies.

Read

Bicep Deployment Stacks: Lifecycle Management Without Manual Cleanup

Deployment Stacks in Bicep are a way to manage Azure resources as a coherent unit with denyAssignments, auto-cleanup, and controlled deletion. Practical guide with a migration plan from classic deployments.

Read

Terraform Azure Modules: Private Registry and Testing

Build reusable Terraform modules for Azure with private registry publishing, automated testing with Terratest, and versioned module consumption in production.

Read