At a glance

For: platform and DevOps engineers who keep shared Bicep modules in one repo · Setup time: about an hour for the version gate · Stack: Bicep CLI 0.47, Azure CLI 2.90, GitHub Actions, Azure Container Registry, PSRule for Azure 1.47
Last verified: 2026-10-15

At the end of this, every Bicep template in your repo covers one lifecycle, every module carries one version, and modules come from a private registry by that version. Each pull request passes three gates before a reviewer opens it: a version check, PSRule for Azure and a what-if. The version gate takes about an hour to set up, and every script is below.

The problem: a new version ships with an old changelog

A module gets a new version in metadata.json. The changelog still shows the old one, the parameter files still point at the previous release, and the reviewer approves because they were reading the Bicep logic. The new version gets published without anyone having deployed it. Before the version gate, that cost us comments about missing updates on almost every PR, PRs waiting for fixes, and production incidents caused by mismatched versions.

I run this setup in one enterprise codebase. Since the version gate went live, no version mismatch has made it into a merged PR, and we've had no version-related production incidents.

Generated IaC optimises the resource, not the repo

The first fix most teams reach for now is an agent. I write Bicep with AI assistance every working day (and often on non-working days too), and a large share of the templates I ship started as something Claude Code or Copilot drafted. So I am exactly the person these tools are built for.

Microsoft's public repo github.com/microsoft/azure-skills has a good example of the genre, azure-enterprise-infra-planner. It aligns a design to the Well-Architected Framework, understands hub-spoke, landing zones, multi-region disaster recovery and private endpoints, asks for approval before it writes code, and checks SKU pairings so you don't request a combination Azure will reject. It now runs a gated pipeline: the generated code has to pass az bicep build or terraform validate and a Checkov scan before it may deploy. For a competent first pass on a blank slate, it is worth studying.

It makes a resource, or an architecture, enterprise-ready. It never looks at the repository: structure, versioning rules, changelog discipline, the exact format of parameter descriptions, whether you reuse a shared type instead of inventing the sixth near-identical shape for the same object. That sits outside its scope, reasonably, because none of it is universal. But that layer is where a codebase either survives a few years of contributors or quietly rots. You can redraw an architecture in an afternoon. Unwinding five conventions for the same object takes months.

And I don't want an agent deciding how a resource should look. We already decided that: we argued and picked constraints for a specific case, and they are not the ones a general best-practices pass would arrive at. Clients pay because their environment is not the generic case. A skill that re-derives best practices on every run is more useful to me as a research assistant: read the current guidance, summarise the trade-offs, show me the articles behind each statement.

A prompt gives a different answer next month

The second fix is to write the conventions into a prompt. A model interprets natural-language guidance fresh each time, so the same prompt can give a different result next month: a new model version, another contributor, a reworded instruction. After six months you have three flavours of the same module and nobody can say which is canonical. I want rules that fire identically every time, whoever or whatever edits the file.

Review reads the logic, not the bookkeeping

The third fix is the PR review. In a review I check whether the Bicep logic makes sense, whether the parameters are right, whether the infrastructure is secure and whether it breaks existing deployments. Version consistency comes last, and sometimes not at all.

The steps below are the conventions I enforce in that codebase and the machinery that holds them in place. The conventions are ours; the scripts are generic enough to copy.

Steps: one version, three gates, six steps

1. Cut the template along a lifecycle

When I start a template I am not making a list of resources. I am deciding which of them belong together as one unit: what gets created and destroyed together, and in what order it would all have to come back. Generators emit a storage account, a VM, a network security group, a vnet, each one correct on its own, with no idea how they fit into a lifecycle.

The test is a disaster. If this environment were gone tomorrow, I want to recreate it from A to Z, in order, without anyone remembering which piece depends on which. The vnet has to exist before I can add a subnet for an application. That order is a property of the system, not of the resource, so no generator can know it.

Before I write a line, I usually sketch the template in Excalidraw, just for myself. Take an app deployment. It references three resources as existing, because they belong to other lifecycles (yellow):

  • the vnet
  • the storage account
  • a user-assigned managed identity

It creates and owns the rest (grey):

  • a subnet inside that existing vnet, and the VMs that sit in it
  • SFTP local users, their certificates, and a couple of blob containers on the existing storage account
  • an app registration, and a role assignment that wires the identity to what it needs
Lifecycle sketch of an app deployment: borrowed resources in yellow, owned resources in grey, identity changes in red
Lifecycle sketch of an app deployment: borrowed resources in yellow, owned resources in grey, identity changes in red

The borrowed three came from other templates, probably owned by other teams on other cadences, so the template has to say what it owns and what it only borrows. Get the line wrong and either you can't tear the app down cleanly, or you recreate a vnet that half the estate sits inside.

Red marks what the template creates but can't deploy with Contributor alone: the app registration and the role assignment both touch identity, so the deployment principal needs more rights. Marking that up front keeps the deployment least-privilege. Five minutes of sketching surface the order and the permission boundaries.

Brownfield makes the line harder. Resources were clicked into existence in the portal over years, the naming is inconsistent, and many predate any convention anyone would defend today. Either you write exceptions into the pattern to absorb them, or you clean up production first and bring it under management afterwards. Exceptions stay in the pattern for good, and every future reader has to learn why this one resource is special. Cleanup is a migration with its own downtime risk and blast radius. There is no default answer. It depends on the resource, the client, and what the inconsistency will cost later.

The infra planner skill moved closer here in August 2026 with a referenced mode: point it at an existing resource group, subscription or IaC and it references what is there as existing and only deploys additively. That is the own-versus-borrow line from the sketch, drawn by a tool. It still sees only what you hand it, and it writes its own infra/main.bicep layout rather than your repo's.

2. Write the conventions where the agent has to load them

Put each convention in a file the agent has to load before it edits a Bicep file. Our parameter descriptions, for example, have one exact shape:

@description(''' 
![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square)

Base name of the virtual network, without prefix or suffix.
- Example: `vnet1`
''')
param parVnetName string

That shape is glue between this repo's conventions and this repo's docs pipeline, so no model produces it on its own. The rule sits in a file scoped to Bicep files. For Claude Code that is .claude/rules/bicep-descriptions.md, which loads only when a matching file is in play:

---
paths: ["**/*.bicep"]
---
Editing an @description() decorator:
- Line 1 after ''' is the shields.io badge: orange = required, green = optional.
- Blank line, then one description sentence.
- Then Default / Example / Hint, in that order. Values in backticks.
- A space after ''' is mandatory or the badge will not render.
- Two-space indentation on nested type properties.

Type reuse runs before creation. Before anyone adds a user-defined type, the setup checks for a similar shared type to refactor towards, so the codebase doesn't collect near-identical shapes. Validators flag a local type that duplicates a shared one, an inline list of values that duplicates a shared enum, and a hardcoded GUID where a named role should be resolved centrally.

3. Give each module one version in three files

Versioning is the most important thing in the whole setup: if the rule is ambiguous, every automation downstream inherits the ambiguity. The team decided once when a change is a patch, a minor or a major, and wired that into every workflow, skill and pipeline. Modules use semantic versioning. Changes move from a development-facing repository into a stable, production one through development tags, release-candidate tags, an explicit promotion step and an auto-merging build agent.

A module's version lives in three files, and all three have to agree. metadata.json is the source of truth and decides what gets published:

{
  "version": {
    "major": 1,
    "minor": 0,
    "patch": 2
  }
}

The template metadata shows the version to anyone who opens the Bicep file:

metadata name = 'Subnet'
metadata owner = 'MFL'
metadata description = 'Deployment eines Subnets in ein VNET'
metadata version = '1.0.2'
metadata type = 'Module'

The top changelog entry says what changed, for anyone judging an update or troubleshooting one. Newest entry on top, a category-and-initials prefix on each line, one date format:

## 1.0.2 - 02.06.2026

Features(MFL):
- Optimierung einzelner Parameter (optional / mandatory / default values) & deren Logik
- `networkSecurityGroup`
- `routeTable`
- `delegations`
- `serviceEndpoints`

The .bicepparam files that deploy a solution follow the version without being part of it. They reference a published version from the registry:

using 'br:myacrname.azurecr.io/bicep-solution/landing-zones/alz:v1.0.2'

An older reference still works. But this is the file people miss: you create v1.0.2, the parameter files still point to v1.0.1, and nobody tests the version you're about to publish. A version-bump skill updates the three files in one step; gate 1 catches it when they disagree anyway.

4. Publish versioned modules to a registry

Application code has npm, pip or Maven for versions. Bicep modules have registries: the versioned modules go to an Azure Container Registry (ACR), and other teams reference them by version. Publishing reads the version from metadata.json, so the tag can't drift from the source of truth:

version=$(jq -r '.version | "\(.major).\(.minor).\(.patch)"' landing-zones/alz/metadata.json)
az bicep publish --file landing-zones/alz/main.bicep --target "br:myacrname.azurecr.io/bicep-solution/landing-zones/alz:v${version}"

Without --force, publishing a tag that already exists fails. Keep it that way: a published version never changes underneath the people using it, and a fix is a new patch version.

5. Gate 1: check the version on every pull request

It took about an hour to build: 30 minutes for the script, 20 for the workflow, 10 for testing. Before it, a PR with a version change cost 5–10 minutes of checking by hand, at about 20 such PRs a month. My conservative estimate is 4–5 hours saved a month.

Before, a PR that bumped metadata.json and forgot the changelog went to review and waited for someone to notice. After, the check fails before a human looks:

✗ version-consistency
  metadata.json   1.4.0
  main.bicep      1.4.0
  CHANGELOG.md    1.3.0   ← no entry for 1.4.0
  merge blocked

The gate has two parts. A GitHub Actions workflow handles GitHub: triggering, commenting and failing the check. A Bash script handles the validation and knows nothing about pull requests, so you can run it locally.

PR Created
    ↓
validate-versioning.yml (GitHub Action)
    ├─ Triggers when version files change
    ├─ Checks out code
    └─ Calls validate-versioning.sh
        ↓
validate-versioning.sh (Bash Script)
    ├─ Extracts version from metadata.json
    ├─ Checks CHANGELOG.md
    ├─ Checks Bicep metadata
    ├─ Checks .bicepparam files
    └─ Returns: ✅ Pass or ❌ Fail
        ↓
validate-versioning.yml
    └─ Posts results as PR comment
        ↓
GitHub blocks merge if validation fails

What it checks:

  • CHANGELOG.md has an entry at the top that matches metadata.json (error)
  • the Bicep metadata version matches metadata.json (error)
  • the .bicepparam files reference the new version (warning only)

Errors block, warnings don't. Old versions keep working, and sometimes you keep an older reference for one environment on purpose, so a stale parameter file only nudges.

Both files as I use them. Put the script in .github/scripts/ and the workflow in .github/workflows/, replace myacrname with your registry and adjust the paths to your repo layout.

validate-versioning.yml
name: Validate Bicep Solution Versioning

on:
  pull_request:
    branches: [ "main" ]
    paths:
      - '**/metadata.json'
      - '**/CHANGELOG.md'
      - '**/*.bicep'
      - '**/*.bicepparam'
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write

jobs:
  validate-versioning:
    runs-on: ubuntu-latest
    name: Validate Version Consistency
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 0  # Fetch all history for proper diff
          ref: ${{ github.event.pull_request.head.ref }}

      - name: Setup Git for comparison
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git fetch origin ${{ github.base_ref }}
          echo "✅ Git configured and base branch fetched"

      - name: Install dependencies
        run: |
          echo "📦 Installing required dependencies..."
          sudo apt-get update -qq
          sudo apt-get install -y jq
          echo "✅ Dependencies installed"
          echo "   jq version: $(jq --version)"
          echo "   bash version: $BASH_VERSION"

      - name: Verify script exists
        run: |
          if [ ! -f .github/scripts/validate-versioning.sh ]; then
            echo "❌ ERROR: validate-versioning.sh not found"
            exit 1
          fi
          echo "✅ Validation script found"

      - name: Make validation script executable
        run: chmod +x .github/scripts/validate-versioning.sh

      - name: Run versioning validation
        id: validation
        env:
          GITHUB_BASE_REF: ${{ github.base_ref }}
        run: |
          set +e  # Don't exit on error, capture output
          ./.github/scripts/validate-versioning.sh > validation_output.txt 2>&1
          validation_exit_code=$?
          cat validation_output.txt
          echo "exit_code=$validation_exit_code" >> "$GITHUB_OUTPUT"
          exit $validation_exit_code

      - name: Comment on PR with validation results
        if: always() && github.event_name == 'pull_request'
        uses: actions/github-script@v9
        with:
          script: |
            const fs = require('fs');
            const output = fs.readFileSync('validation_output.txt', 'utf8');
            const exitCode = '${{ steps.validation.outputs.exit_code }}';
            
            let emoji = '✅';
            let title = 'Versioning Validation Passed';
            let color = '28a745';
            
            if (exitCode !== '0') {
              emoji = '❌';
              title = 'Versioning Validation Failed';
              color = 'd73a49';
            } else if (output.includes('WARNING')) {
              emoji = '⚠️';
              title = 'Versioning Validation Passed with Warnings';
              color = 'ffd33d';
            }
            
            const body = `## ${emoji} ${title}
            
            <details>
            <summary>📋 Validation Details</summary>
            
            \`\`\`
            ${output}
            \`\`\`
            
            </details>
            
            ---
            
            ### 📝 Version Consistency Requirements
            
            When updating \`metadata.json\`, ensure:
            
            1. **CHANGELOG.md** contains a section at the top with the new version:
               \`\`\`markdown
               ## X.Y.Z - DD.MM.YYYY
               
               Features(Initials):
               - Description in German
               \`\`\`
            
            2. **Main Bicep file** (alz.bicep or main.bicep) contains matching metadata:
               \`\`\`bicep
               metadata version = 'X.Y.Z'
               \`\`\`
            
            3. **.bicepparam files** in \`configurations/\` folder should reference the latest version:
               \`\`\`bicep
               using 'br:myacrname.azurecr.io/bicep-solution/{path}:vX.Y.Z'
               \`\`\`
               *(This is a warning - old references will still work but should be updated)*
            `;
            
            // Find existing comment
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            
            const botComment = comments.find(comment => 
              comment.user.type === 'Bot' && 
              comment.body.includes('Versioning Validation')
            );
            
            if (botComment) {
              // Update existing comment
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: body
              });
            } else {
              // Create new comment
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: body
              });
            }

      - name: Upload validation output
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: validation-output
          path: validation_output.txt
          retention-days: 7
validate-versioning.sh
#!/bin/bash
set -e

# Colors for output (work in both local and CI environments)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Counters
errors=0
warnings=0

echo "🔍 Bicep Solution Versioning Validation"
echo "========================================"
echo ""

# Check required commands
for cmd in jq find grep sed; do
  if ! command -v $cmd &> /dev/null; then
    echo -e "${RED}❌ ERROR: Required command '$cmd' not found${NC}"
    exit 1
  fi
done

# Get list of changed files
if [ -z "$GITHUB_BASE_REF" ]; then
  echo "ℹ️  Running in local mode - checking all solutions"
  changed_files=$(find . -name "metadata.json" | grep -E "(landing-zones|workload-solutions)" || echo "")
else
  echo "ℹ️  Running in PR mode - checking changed files only"
  echo "   Base branch: $GITHUB_BASE_REF"
  
  # Ensure we have the latest base branch
  git fetch origin "$GITHUB_BASE_REF" 2>/dev/null || echo "   Note: Branch already fetched"
  
  # Get changed files (handle case where grep finds nothing)
  changed_files=$(git diff --name-only "origin/$GITHUB_BASE_REF...HEAD" 2>/dev/null | grep "metadata.json" || echo "")
fi

if [ -z "$changed_files" ]; then
  echo "✅ No metadata.json files changed - skipping validation"
  exit 0
fi

echo "📋 Changed metadata.json files:"
echo "$changed_files"
echo ""

# Process each changed metadata.json
while IFS= read -r metadata_file; do
  if [ -z "$metadata_file" ]; then
    continue
  fi

  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  echo "📦 Validating: $metadata_file"
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  
  solution_dir=$(dirname "$metadata_file")
  
  # Extract version from metadata.json
  if [ ! -f "$metadata_file" ]; then
    echo -e "${RED}❌ ERROR: metadata.json not found at $metadata_file${NC}"
    errors=$((errors + 1))
    continue
  fi
  
  major=$(jq -r '.version.major' < "$metadata_file")
  minor=$(jq -r '.version.minor' < "$metadata_file")
  patch=$(jq -r '.version.patch' < "$metadata_file")
  
  if [ "$major" == "null" ] || [ "$minor" == "null" ] || [ "$patch" == "null" ]; then
    echo -e "${RED}❌ ERROR: Invalid version format in metadata.json${NC}"
    errors=$((errors + 1))
    continue
  fi
  
  version="${major}.${minor}.${patch}"
  echo "📌 Version from metadata.json: $version"
  
  # Find main bicep file (alz.bicep or main.bicep)
  main_bicep=""
  if [ -f "$solution_dir/alz.bicep" ]; then
    main_bicep="$solution_dir/alz.bicep"
  elif [ -f "$solution_dir/main.bicep" ]; then
    main_bicep="$solution_dir/main.bicep"
  else
    echo -e "${RED}❌ ERROR: No main bicep file found (expected alz.bicep or main.bicep)${NC}"
    errors=$((errors + 1))
    continue
  fi
  
  echo "📄 Main bicep file: $main_bicep"
  
  # Check 1: Validate CHANGELOG.md contains version section
  changelog="$solution_dir/CHANGELOG.md"
  echo ""
  echo "🔍 Checking CHANGELOG.md..."
  
  if [ ! -f "$changelog" ]; then
    echo -e "${RED}❌ ERROR: CHANGELOG.md not found${NC}"
    errors=$((errors + 1))
  else
    # Look for version header in CHANGELOG (format: ## X.Y.Z - DD.MM.YYYY)
    if grep -qE "^## ${version} - [0-9]{2}\.[0-9]{2}\.[0-9]{4}" "$changelog"; then
      # Check if this version is the first entry (most recent)
      first_version=$(grep -m 1 -oE "^## [0-9]+\.[0-9]+\.[0-9]+" "$changelog" | sed 's/## //')
      if [ "$first_version" == "$version" ]; then
        echo -e "${GREEN}✅ CHANGELOG.md contains version $version at the top${NC}"
      else
        echo -e "${RED}❌ ERROR: Version $version found in CHANGELOG but not at the top (found: $first_version)${NC}"
        errors=$((errors + 1))
      fi
    else
      echo -e "${RED}❌ ERROR: CHANGELOG.md missing version section for $version${NC}"
      echo -e "${YELLOW}   Expected format: ## $version - DD.MM.YYYY${NC}"
      errors=$((errors + 1))
    fi
  fi
  
  # Check 2: Validate main bicep file contains metadata version
  echo ""
  echo "🔍 Checking main bicep file metadata..."
  
  if grep -q "metadata version" "$main_bicep"; then
    bicep_version=$(grep "metadata version" "$main_bicep" | sed -E "s/.*'([0-9]+\.[0-9]+\.[0-9]+)'.*/\1/")
    if [ "$bicep_version" == "$version" ]; then
      echo -e "${GREEN}✅ Main bicep metadata version matches: $version${NC}"
    else
      echo -e "${RED}❌ ERROR: Main bicep metadata version mismatch${NC}"
      echo -e "${YELLOW}   Expected: $version, Found: $bicep_version${NC}"
      errors=$((errors + 1))
    fi
  else
    echo -e "${RED}❌ ERROR: metadata version not found in main bicep file${NC}"
    errors=$((errors + 1))
  fi
  
  # Check 3: Check bicepparam files for version references
  echo ""
  echo "🔍 Checking .bicepparam files in configurations/ subfolder..."
  
  # Check if configurations folder exists
  if [ ! -d "$solution_dir/configurations" ]; then
    echo -e "${YELLOW}⚠️  WARNING: configurations/ subfolder not found${NC}"
    echo -e "${YELLOW}   Expected location: $solution_dir/configurations/${NC}"
    warnings=$((warnings + 1))
  else
    bicepparam_files=$(find "$solution_dir/configurations" -name "*.bicepparam" 2>/dev/null || echo "")
    
    if [ -z "$bicepparam_files" ]; then
      echo -e "${YELLOW}⚠️  WARNING: No .bicepparam files found in configurations/${NC}"
      warnings=$((warnings + 1))
    else
      bicepparam_count=$(echo "$bicepparam_files" | wc -l | xargs)
      echo "   Found $bicepparam_count .bicepparam file(s) in configurations/"
      
      # Extract solution path for ACR reference
      # Format: landing-zones/alz-platform or workload-solutions/vwan
      solution_path=$(echo "$solution_dir" | sed 's|^\./||')
      expected_acr_ref="bicep-solution/${solution_path}:v${version}"
      
      outdated_params=()
      
      while IFS= read -r param_file; do
        if [ -z "$param_file" ]; then
          continue
        fi
        
        param_name=$(basename "$param_file")
        
        # Verify file is actually in configurations/ subfolder (extra safety check)
        if [[ "$param_file" != *"/configurations/"* ]]; then
          echo -e "   ${RED}❌ ERROR: $param_name is not in configurations/ subfolder${NC}"
          errors=$((errors + 1))
          continue
        fi
        
        # Check if file contains ACR reference with correct version
        if grep -q "using 'br:.*bicep-solution/${solution_path}:v" "$param_file"; then
          current_version=$(grep "using 'br:.*bicep-solution/${solution_path}:v" "$param_file" | sed -E "s/.*:v([0-9]+\.[0-9]+\.[0-9]+).*/\1/")
          if [ "$current_version" == "$version" ]; then
            echo -e "   ${GREEN}✅ $param_name references v$version${NC}"
          else
            echo -e "   ${YELLOW}⚠️  $param_name references v$current_version (not v$version)${NC}"
            outdated_params+=("$param_name")
          fi
        else
          echo -e "   ${YELLOW}⚠️  $param_name does not contain expected ACR reference${NC}"
          outdated_params+=("$param_name")
        fi
      done <<< "$bicepparam_files"
      
      if [ ${#outdated_params[@]} -gt 0 ]; then
        echo ""
        echo -e "${YELLOW}⚠️  WARNING: ${#outdated_params[@]} .bicepparam file(s) may need updating:${NC}"
        for param in "${outdated_params[@]}"; do
          echo -e "${YELLOW}   - $param${NC}"
        done
        echo -e "${YELLOW}   Expected ACR reference: br:myacrname.azurecr.io/${expected_acr_ref}${NC}"
        warnings=$((warnings + 1))
      fi
    fi
  fi
  
  echo ""
done <<< "$changed_files"

# Summary
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📊 Validation Summary"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

if [ $errors -eq 0 ] && [ $warnings -eq 0 ]; then
  echo -e "${GREEN}✅ All validation checks passed!${NC}"
  exit 0
elif [ $errors -eq 0 ]; then
  echo -e "${YELLOW}⚠️  Validation passed with $warnings warning(s)${NC}"
  echo -e "${YELLOW}   Please review .bicepparam files to ensure they reference the latest version${NC}"
  exit 0
else
  echo -e "${RED}❌ Validation failed with $errors error(s) and $warnings warning(s)${NC}"
  echo ""
  echo -e "${RED}Required fixes:${NC}"
  echo -e "${RED}1. Update CHANGELOG.md to include version section at the top${NC}"
  echo -e "${RED}   Format: ## X.Y.Z - DD.MM.YYYY${NC}"
  echo -e "${RED}2. Update main bicep file metadata version to match metadata.json${NC}"
  echo -e "${RED}   Format: metadata version = 'X.Y.Z'${NC}"
  exit 1
fi

Then make the check a required status check in a ruleset or branch protection rule on main; otherwise a red check still lets the merge through. To run it before you push, call it without GITHUB_BASE_REF and it checks every solution in the repo:

./.github/scripts/validate-versioning.sh

On a passing PR, the comment and the log look like this:

PR comment from the version check: validation passed, with the version consistency requirements
PR comment from the version check: validation passed, with the version consistency requirements
Workflow log of the version check: changelog, Bicep metadata and four parameter files all at v0.3.0
Workflow log of the version check: changelog, Bicep metadata and four parameter files all at v0.3.0

Every comment repeats the rules: the changelog format, where the Bicep metadata goes, and why the parameter references matter. New contributors know the pattern after their first PR, and "Can you update the CHANGELOG?" no longer shows up on almost every PR.

6. Gates 2 and 3: PSRule and what-if

The version gate checks the bookkeeping. The other two check what the template would do. PSRule for Azure tests the expanded template against rules based on the Well-Architected Framework. What-if asks Azure what a deployment would create, change or delete in a real environment, without deploying.

Both need the template as this PR changes it. The parameter files point at a registry version that isn't published yet, so the pipeline first writes a CI copy of each parameter file that points at the local template:

#!/bin/bash
# Copy each .bicepparam to .ci.bicepparam, pointed at the template in this pull request
for param_file in landing-zones/*/configurations/*.bicepparam; do
  case "$param_file" in *.ci.bicepparam) continue ;; esac
  sed "s|^using .*|using '../main.bicep'|" "$param_file" > "${param_file%.bicepparam}.ci.bicepparam"
done

Save it as .github/scripts/point-to-local.sh and add *.ci.bicepparam to .gitignore. PSRule reads ps-rule.yaml in the repo root. This one makes it look only at the CI copies and expand each into the full deployment:

include:
  module:
    - PSRule.Rules.Azure

configuration:
  AZURE_BICEP_PARAMS_FILE_EXPANSION: true

input:
  pathIgnore:
    - '**'
    - '!**/*.ci.bicepparam'

The workflow signs in to Azure with OpenID Connect (a federated credential on an app registration, so no secret in the repo), runs the what-if against the dev resource group and posts the result to the pull request. AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID and DEV_RESOURCE_GROUP are repository variables. Give that identity only the rights it needs on the dev resource group.

bicep-checks.yml
name: Bicep checks

on:
  pull_request:
    branches: [ "main" ]
    paths:
      - '**/*.bicep'
      - '**/*.bicepparam'

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  psrule-and-what-if:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v7

      - name: Point parameter files at the local templates
        run: bash .github/scripts/point-to-local.sh

      - name: Run PSRule for Azure
        uses: microsoft/ps-rule@v2.9.0
        with:
          modules: PSRule.Rules.Azure

      - name: Sign in to Azure
        uses: azure/login@v3
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: What-if against dev
        env:
          AZURE_CORE_NO_COLOR: 'true'
          RESOURCE_GROUP: ${{ vars.DEV_RESOURCE_GROUP }}
        run: |
          az deployment group what-if \
            --resource-group "$RESOURCE_GROUP" \
            --parameters landing-zones/alz/configurations/dev.ci.bicepparam \
            --exclude-change-types NoChange Ignore \
            > what-if.txt

      - name: Post the what-if to the pull request
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          {
            echo '### What-if: dev'
            echo '```'
            cat what-if.txt
            echo '```'
          } > what-if.md
          gh pr comment "$PR_NUMBER" --body-file what-if.md

Read the what-if for deletes and replacements. If the code wants to recreate a public IP, you need to know everywhere that address is in use: a firewall rule, a VPN tunnel another customer connects through, a DNS record, an allow-list kept by a team you have never spoken to. That depends on the rest of the estate, sometimes an estate you don't control, and the decision stays with a person.

Pitfalls

  • A warning that blocked the merge. ((errors++)) under set -e ends a Bash script when the counter is 0, because the expression evaluates to 0 and returns a failure. On GitHub's Ubuntu runners the first warning failed the whole check; on a Mac with bash 3.2 it didn't show. The script above counts with errors=$((errors + 1)).
  • Old Node runtimes. GitHub removed Node 20 from its runners on 23 September 2026. The workflows use checkout@v7, github-script@v9, upload-artifact@v7 and azure/login@v3. In github-script v9, require('@actions/github') no longer works; the comment script only uses the injected github and context objects and Node's own fs.
  • A required check with a path filter. Both workflows only run when certain files change. If you make one of them required, a PR that touches none of those files never runs it, and GitHub leaves the required check pending, so the merge waits. For a required check, drop the paths: filter: the version script exits 0 when no metadata.json changed.
  • The mandatory space. The space after the opening ''' in a description is easy to delete and silently breaks the badge. That's why it's in the rule file.
  • Brownfield exceptions. Every exception you write into a pattern stays there, and every future reader has to learn why that one resource is special.

FAQ

Why not let an AI agent generate the whole template?

The hard decisions aren't in the resource definition: what belongs to one lifecycle, in what order it comes back, what to do with what is already there. I let AI draft the Bicep, and the conventions and the gates decide whether it gets merged.

Why does the version live in three files instead of one?

Each one has a reader. metadata.json drives publishing, the template metadata is what you see when you open the file, and the changelog is what you read before an update. The gate keeps them in agreement.

Do the warnings block the merge?

No. A missing changelog entry or a mismatched Bicep version blocks. A parameter file that still references an older version only warns.

Can I run the version check without GitHub?

Yes. The script has no GitHub logic. Without GITHUB_BASE_REF it checks every metadata.json under landing-zones/ and workload-solutions/; adjust that path filter to your layout.

Why do PSRule and what-if use a copy of the parameter files?

The real parameter files reference the version the PR is about to create, which isn't in the registry yet, so they can't be built. The copy points at the template in the PR, so both gates see exactly what would be published.

History

  • 17 Oct 2026 New title and section headings that lead with the outcome; the URL stays.
  • 15 Oct 2026 Turned into a use case together with the version-check post from December 2025, which now redirects here. New steps for publishing to a registry, PSRule and what-if. Fixed the version check: a warning on its own failed the job on GitHub's Ubuntu runners.
  • 25 Sep 2026 Microsoft's infra planner skill gained a brownfield mode in August, and the version example now agrees with itself. The version check's actions moved to their current major versions, because GitHub removed the old Node runtimes from its runners.
  • 2 Jun 2026 First version, as The IaC Conventions Nobody Shares, and Why They Matter Most.
  • 1 Dec 2025 Version check first published as Catch Version Inconsistencies Before They Break Your Bicep Deployments.

Bicep IaC: one version per module, checked on every pull request

Every Bicep module in your repo gets one version that CI checks on every pull request, modules come from a private registry, and each PR passes a version check, PSRule and what-if before review. The version gate takes about an hour to set up; all scripts included.

I keep each use case current and add new ones as I build them. Get them by email, nothing else.

Subscribe