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

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('''

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:


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++))underset -eends 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 witherrors=$((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@v7andazure/login@v3. Ingithub-scriptv9,require('@actions/github')no longer works; the comment script only uses the injectedgithubandcontextobjects and Node's ownfs. - 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 nometadata.jsonchanged. - 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