Manual Bicep Deployment Does Not Scale? Deploy with GitHub Actions

Published on:

Running every Bicep command manually from one computer makes deployment depend on one person’s tools, Azure session, and memory. A skipped validation step or an uncommitted local file can produce a different result.

Move the tested commands into GitHub Actions. Pull requests will validate the code without Azure credentials. After approved code reaches main, a separate job will authenticate with OpenID Connect (OIDC), check the live TEST environment, deploy it, and verify the storage account.

GitHub Actions is the automation platform. The YAML workflow is the pipeline, comparable to an Azure DevOps YAML pipeline; jobs contain the steps that run on hosted agents called runners.

Understand the Workflow

GitHub event Result
Pull request Lint both entry points and validate the committed TEST snapshot
Push to main Validate, sign in to Azure, run preflight and what-if, deploy TEST, and verify it
Manual run Run the same validated TEST deployment on demand

The deployment targets the existing rg-cloudtrips-bicep-test-weu resource group. It does not need subscription-level permission to create a resource group.

Prepare the Azure Identity

Create a dedicated Microsoft Entra app registration for this workflow. This keeps the Bicep deployment identity and its resource-group permissions separate from every other automation identity.

In the Azure portal, open:

Microsoft Entra ID > App registrations > New registration

Enter CloudTrips Bicep TEST GitHub as the name, select Accounts in this organizational directory only, leave Redirect URI empty, and select Register.

On Overview, copy the Application (client) ID and Directory (tenant) ID. Then open Certificates & secrets > Federated credentials > Add credential.

Configure a GitHub Actions environment credential with:

  • Organization: your GitHub repository owner
  • Organization ID: the repository-owner ID returned by GitHub
  • Repository: cloudtrips
  • Repository ID: the repository ID returned by GitHub
  • Entity type: Environment
  • Environment: cloudtrips-bicep-test
  • Credential name: github-cloudtrips-bicep-test

Entra labels the repository-owner field Organization, even when the owner is a personal GitHub account. To retrieve the IDs, replace YOUR_GITHUB_OWNER and run these commands in a terminal after gh auth login:

gh api users/YOUR_GITHUB_OWNER \
  --jq .id

gh api repos/YOUR_GITHUB_OWNER/cloudtrips \
  --jq .id

For an immutable GitHub OIDC subject, the generated identifier follows this format:

repo:YOUR_GITHUB_OWNER@YOUR_OWNER_ID/cloudtrips@YOUR_REPOSITORY_ID:environment:cloudtrips-bicep-test

The @ values are expected because this repository uses GitHub’s immutable OIDC subject format. Confirm the setting with:

gh api repos/YOUR_GITHUB_OWNER/cloudtrips/actions/oidc/customization/sub

The result should include "use_immutable_subject": true. Keep the generated subject unchanged. The issuer must be https://token.actions.githubusercontent.com and the audience must be api://AzureADTokenExchange.

The full subject is stored in Entra on the federated credential under Subject identifier. GitHub does not store a second editable copy. It generates the token’s sub claim from the repository identity and this line in the deployment job:

environment: cloudtrips-bicep-test

After the workflow runs, open Actions > the workflow run > deploy > Sign in to Azure with OIDC. The log shows Federated token details and its subject claim. That runtime claim must equal Entra’s Subject identifier character for character. Do not add the subject as a GitHub secret.

Choose the Correct Entity Type

The entity type determines which GitHub context is written into the OIDC token’s subject:

Entity type Subject is restricted to Typical use
Environment environment:<name> Deployments using environment secrets, branch restrictions, or approvals
Branch ref:refs/heads/<branch> A branch job that does not declare a GitHub environment
Pull request pull_request A job that needs Azure access while validating a pull request
Tag ref:refs/tags/<tag> Deployments started from a release tag

Select Environment here because the deployment job contains:

environment: cloudtrips-bicep-test

When a job declares an environment, GitHub places the environment in sub instead of the triggering branch. Selecting Branch with main would therefore not match this workflow and Azure login would fail. The pull-request job deliberately has no environment and no Azure login.

Grant Only the Required Azure Access

Open rg-cloudtrips-bicep-test-weu, and then select Access control (IAM) > Check access. Search for the new CloudTrips Bicep TEST GitHub enterprise application, which Microsoft Entra created for the app registration.

If it does not already inherit sufficient access, add the Contributor role at this resource-group scope. Select the enterprise application by name as the member.

The role belongs to the service principal represented by the enterprise application. GitHub still signs in with the app registration’s Application (client) ID, not its object ID.

Create the GitHub Environment

In GitHub, open the cloudtrips repository and select Settings > Environments > New environment. Name it:

cloudtrips-bicep-test

Under Deployment branches and tags, change No restriction to Selected branches and tags. Select Add deployment branch or tag rule, choose Branch, enter main, and add the rule. Only workflow runs whose GITHUB_REF matches the main branch can now use this environment.

A required reviewer is optional for TEST; if configured, every deployment waits for that approval.

For a private repository, deployment-branch restrictions require GitHub Pro, Team, or Enterprise. If the section is missing, check the repository’s plan.

Add these environment secrets:

Secret Value
AZURE_CLIENT_ID Application (client) ID from the app registration
AZURE_TENANT_ID Directory (tenant) ID
AZURE_SUBSCRIPTION_ID Subscription ID of CloudTrips TEST

No client secret is required. The workflow’s environment name, federated credential subject, and GitHub environment must match exactly.

CloudTrips Bicep TEST GitHub environment with the three Azure OIDC secret names configured

Create the Workflow

Create .github/workflows/deploy-cloudtrips-bicep.yml:

name: Validate and deploy CloudTrips Bicep

on:
  pull_request:
    paths:
      - 'cloudtrips-bicep/**'
      - '.github/workflows/deploy-cloudtrips-bicep.yml'
  push:
    branches:
      - main
    paths:
      - 'cloudtrips-bicep/**'
      - '.github/workflows/deploy-cloudtrips-bicep.yml'
  workflow_dispatch:

concurrency:
  group: cloudtrips-bicep-test
  cancel-in-progress: false

jobs:
  validate:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Install current Bicep CLI
        shell: bash
        run: |
          set -euo pipefail
          az bicep upgrade
          "$HOME/.azure/bin/bicep" --version

      - name: Lint Bicep entry points
        shell: bash
        run: |
          set -euo pipefail
          az bicep lint --file cloudtrips-bicep/main.bicep
          az bicep lint --file cloudtrips-bicep/subscription.bicep

      - name: Validate TEST snapshot
        shell: bash
        run: |
          set -euo pipefail
          "$HOME/.azure/bin/bicep" snapshot cloudtrips-bicep/environments/test.bicepparam \
            --mode validate \
            --subscription-id 00000000-0000-0000-0000-000000000000 \
            --resource-group rg-cloudtrips-bicep-test-weu \
            --location westeurope \
            --deployment-name deploy-cloudtrips-storage-test

  deploy:
    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
    needs: validate
    runs-on: ubuntu-latest
    environment: cloudtrips-bicep-test
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

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

      - name: Run Azure preflight validation
        shell: bash
        run: |
          set -euo pipefail
          az deployment group validate \
            --name validate-cloudtrips-storage-test \
            --resource-group rg-cloudtrips-bicep-test-weu \
            --parameters cloudtrips-bicep/environments/test.bicepparam \
            --validation-level Provider \
            --query "properties.provisioningState" \
            --output tsv

      - name: Preview Azure changes
        shell: bash
        run: |
          set -euo pipefail
          az deployment group what-if \
            --resource-group rg-cloudtrips-bicep-test-weu \
            --parameters cloudtrips-bicep/environments/test.bicepparam \
            --validation-level Provider

      - name: Deploy CloudTrips TEST
        id: deploy
        shell: bash
        run: |
          set -euo pipefail
          deployment_name="deploy-cloudtrips-storage-test-${GITHUB_RUN_NUMBER}"
          az deployment group create \
            --name "$deployment_name" \
            --resource-group rg-cloudtrips-bicep-test-weu \
            --parameters cloudtrips-bicep/environments/test.bicepparam \
            --query "properties.{state:provisioningState,outputs:outputs}" \
            --output yaml
          echo "deployment-name=$deployment_name" >> "$GITHUB_OUTPUT"

      - name: Verify the deployed storage account
        shell: bash
        run: |
          set -euo pipefail
          storage_account_name="$(az deployment group show \
            --name "${{ steps.deploy.outputs.deployment-name }}" \
            --resource-group rg-cloudtrips-bicep-test-weu \
            --query "properties.outputs.storageAccountName.value" \
            --output tsv)"
          az storage account show \
            --name "$storage_account_name" \
            --resource-group rg-cloudtrips-bicep-test-weu \
            --query "{name:name,state:provisioningState,sku:sku.name}" \
            --output table

Only the deploy job receives id-token: write and the environment secrets. Pull-request validation therefore cannot sign in to Azure. concurrency prevents two TEST deployments from running simultaneously.

Test the Pull-Request Pipeline

Commit the workflow, Bicep files, parameter files, and snapshot to a feature branch. Open a pull request into main.

Open the pull request’s Checks section. The validate job should lint the Bicep entry points and confirm that test.snapshot.json still matches. The deploy job is skipped because pull requests must not change TEST.

Successful pull-request validation with the Azure deployment job skipped

Deploy from main

Merge the approved pull request. Open Actions > Validate and deploy CloudTrips Bicep, and then open the run triggered by the push to main.

The workflow should:

  1. complete validate
  2. obtain a short-lived Azure token through OIDC
  3. return Succeeded from preflight
  4. show the what-if result
  5. deploy with a name ending in the GitHub run number
  6. display the storage account’s name, state, and SKU

If the GitHub environment has a required reviewer, select Review deployments, choose cloudtrips-bicep-test, and approve it.

Successful main-branch CloudTrips Bicep deployment and storage-account verification jobs

What Is the Azure DevOps Equivalent?

Azure DevOps can run the same CI/CD design. It is an alternative pipeline engine, not an extra layer required by Bicep.

GitHub Actions Azure DevOps equivalent
Workflow in .github/workflows YAML pipeline
runs-on: ubuntu-latest Microsoft-hosted agent in pool
GitHub environment Azure DevOps environment
Environment secrets and azure/login Azure Resource Manager service connection and AzureCLI@2
Environment deployment-branch rule Branch control check
GITHUB_RUN_NUMBER Build.BuildId

The Bicep files and Azure CLI commands do not change. Authentication and pipeline syntax change.

Create the Azure DevOps Connection

In an Azure DevOps project, open Project settings > Service connections

New service connection > Azure Resource Manager.

Select App registration (automatic) with Workload identity federation, then configure:

  • Scope level: Subscription
  • Subscription: CloudTrips TEST
  • Resource group: rg-cloudtrips-bicep-test-weu
  • Service connection name: sc-cloudtrips-bicep-test

Leave Grant access permission to all pipelines cleared. This limits the connection to explicitly authorized pipelines. The resource-group selection also limits the generated identity’s Azure access to the deployment target. No client secret and no AZURE_CLIENT_ID, AZURE_TENANT_ID, or AZURE_SUBSCRIPTION_ID pipeline variables are needed.

The automatic option requires sufficient permission to create the app registration and role assignment; Microsoft documents Owner on the target subscription for this flow. If that option is unavailable, ask the Azure administrator to create the service connection instead of switching to a long-lived client secret.

Azure DevOps workload-identity service connection scoped to the CloudTrips TEST resource group

In Pipelines > Environments, create cloudtrips-bicep-test. Open Approvals and checks, add Branch control, and allow refs/heads/main. An approval check is optional for TEST.

Add the Equivalent Pipeline

Create azure-pipelines.yml in the repository. It contains the same two-stage design:

  • Validate runs on pull requests and main, lints both entry points, and validates the committed TEST snapshot.
  • Deploy runs only from refs/heads/main, consumes the protected cloudtrips-bicep-test environment, and uses AzureCLI@2 with the service connection to run preflight, what-if, deployment, and verification.

Create the complete azure-pipelines.yml file:

name: cloudtrips-bicep-$(Date:yyyyMMdd).$(Rev:r)

trigger:
  batch: true
  branches:
    include:
      - main
  paths:
    include:
      - cloudtrips-bicep/**
      - azure-pipelines.yml

pr:
  autoCancel: true
  branches:
    include:
      - main
  paths:
    include:
      - cloudtrips-bicep/**
      - azure-pipelines.yml

variables:
  azureServiceConnection: sc-cloudtrips-bicep-test
  resourceGroupName: rg-cloudtrips-bicep-test-weu

stages:
  - stage: Validate
    displayName: Validate Bicep
    jobs:
      - job: Validate
        pool:
          vmImage: ubuntu-latest
        steps:
          - checkout: self

          - bash: |
              set -euo pipefail
              az bicep upgrade
              "$HOME/.azure/bin/bicep" --version
            displayName: Install current Bicep CLI

          - bash: |
              set -euo pipefail
              az bicep lint --file cloudtrips-bicep/main.bicep
              az bicep lint --file cloudtrips-bicep/subscription.bicep
            displayName: Lint Bicep entry points

          - bash: |
              set -euo pipefail
              "$HOME/.azure/bin/bicep" snapshot cloudtrips-bicep/environments/test.bicepparam \
                --mode validate \
                --subscription-id 00000000-0000-0000-0000-000000000000 \
                --resource-group "$(resourceGroupName)" \
                --location westeurope \
                --deployment-name deploy-cloudtrips-storage-test
            displayName: Validate TEST snapshot

  - stage: Deploy
    displayName: Deploy CloudTrips TEST
    dependsOn: Validate
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployTest
        displayName: Preview, deploy, and verify
        pool:
          vmImage: ubuntu-latest
        environment: cloudtrips-bicep-test
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self

                - task: AzureCLI@2
                  displayName: Deploy with workload identity federation
                  inputs:
                    azureSubscription: $(azureServiceConnection)
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      set -euo pipefail
                      az bicep upgrade

                      az deployment group validate \
                        --name validate-cloudtrips-storage-test \
                        --resource-group "$(resourceGroupName)" \
                        --parameters cloudtrips-bicep/environments/test.bicepparam \
                        --validation-level Provider \
                        --query "properties.provisioningState" \
                        --output tsv

                      az deployment group what-if \
                        --resource-group "$(resourceGroupName)" \
                        --parameters cloudtrips-bicep/environments/test.bicepparam \
                        --validation-level Provider

                      deployment_name="deploy-cloudtrips-storage-test-$(Build.BuildId)"
                      az deployment group create \
                        --name "$deployment_name" \
                        --resource-group "$(resourceGroupName)" \
                        --parameters cloudtrips-bicep/environments/test.bicepparam \
                        --query "properties.{state:provisioningState,outputs:outputs}" \
                        --output yaml

                      storage_account_name="$(az deployment group show \
                        --name "$deployment_name" \
                        --resource-group "$(resourceGroupName)" \
                        --query "properties.outputs.storageAccountName.value" \
                        --output tsv)"
                      az storage account show \
                        --name "$storage_account_name" \
                        --resource-group "$(resourceGroupName)" \
                        --query "{name:name,state:provisioningState,sku:sku.name}" \
                        --output table

AzureCLI@2 signs in through the workload-identity service connection before running the script. It is the Azure DevOps equivalent of azure/login; the inline Azure CLI commands remain the same.

In Azure DevOps, select Pipelines > New pipeline > GitHub, choose the cloudtrips repository, select Existing Azure Pipelines YAML file, and choose /azure-pipelines.yml.

On the first run, Azure DevOps can report that the pipeline needs permission to use sc-cloudtrips-bicep-test. Select View > Permit to authorize only this pipeline.

For this GitHub repository, the YAML pr trigger validates pull requests and the deployment stage is skipped. After the change reaches main, the deployment stage can use the protected environment and service connection. If the code is later moved to Azure Repos, configure Repos > Branches > main > Branch policies > Build validation because Azure Repos does not use the YAML pr trigger.

Successful Azure Pipelines run with validation followed by the CloudTrips TEST deployment stage

CloudTrips TEST is now deployed from reviewed repository state instead of one developer’s workstation. Pull requests provide continuous integration, while the protected main deployment provides continuous delivery without storing an Azure password in GitHub or Azure DevOps.