Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 264 additions & 0 deletions .github/workflows/deploy-sovereign-lz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
# Sovereign Cloud Platform Landing Zone - Deployment Pipeline
#
# Three-stage pipeline: validate -> what-if -> deploy
# Uses OIDC federation for Azure authentication (no client secrets).
# See REUSE_CATALOG.md for the full mapping of repository assets to
# sovereign landing zone modules.

name: Deploy Sovereign Landing Zone

on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Target environment (dev or prod)'
required: true
type: choice
options:
- dev
- prod
default: dev
Comment on lines +17 to +24
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workflow_dispatch defines an environment input (dev/prod), but this name is easy to confuse with the GitHub Actions job environment that provides deployment protection. If this input is only meant to select a parameters file, consider renaming it (e.g., parameters_profile) to avoid accidental assumptions about where the deployment is going.

Copilot uses AI. Check for mistakes.
dry_run:
description: 'Dry run -- run validate and what-if only, skip deploy'
required: true
type: boolean
default: true

# OIDC federation requires id-token: write.
# pull-requests: write is needed to post what-if results as PR comments.
permissions:
id-token: write
contents: read
pull-requests: write

env:
DEPLOYMENT_LOCATION: westeurope
DEPLOYMENT_NAME: sovereign-lz-${{ github.run_number }}

jobs:
# --------------------------------------------------------------------------
# VALIDATE -- compile, lint, and validate the Bicep template
# --------------------------------------------------------------------------
validate:
name: Validate
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Log in to Azure with OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Comment on lines +54 to +59
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this runs on pull_request, azure/login will fail for PRs coming from forks (GitHub does not pass repository secrets to forked PR workflows). If external contributions are expected, consider adding an if: guard to skip Azure-authenticated steps when github.event.pull_request.head.repo.fork == true, so CI doesn’t hard-fail on forked PRs.

Copilot uses AI. Check for mistakes.

- name: Lint Bicep template
# Catches style and best-practice issues before compilation.
run: az bicep lint --file deployments/sovereign-baseline/main.bicep

- name: Compile Bicep to ARM template
run: |
mkdir -p dist
az bicep build \
--file deployments/sovereign-baseline/main.bicep \
--outfile dist/sovereign-lz.json

- name: Determine parameter file
id: params
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/prod.parameters.json"
else
PARAMS_FILE="deployments/sovereign-baseline/dev.parameters.json"
Comment on lines +63 to +80
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow references deployments/sovereign-baseline/main.bicep, but this path doesn’t exist in the repository (and there is no deployments/ directory). As written, az bicep lint/build will fail immediately; update the workflow to point at the actual Bicep entrypoint committed to the repo (or add the missing deployment template in this PR).

Suggested change
run: az bicep lint --file deployments/sovereign-baseline/main.bicep
- name: Compile Bicep to ARM template
run: |
mkdir -p dist
az bicep build \
--file deployments/sovereign-baseline/main.bicep \
--outfile dist/sovereign-lz.json
- name: Determine parameter file
id: params
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/prod.parameters.json"
else
PARAMS_FILE="deployments/sovereign-baseline/dev.parameters.json"
run: az bicep lint --file main.bicep
- name: Compile Bicep to ARM template
run: |
mkdir -p dist
az bicep build \
--file main.bicep \
--outfile dist/sovereign-lz.json
- name: Determine parameter file
id: params
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
PARAMS_FILE="${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
PARAMS_FILE="prod.parameters.json"
else
PARAMS_FILE="dev.parameters.json"

Copilot uses AI. Check for mistakes.
fi
echo "file=$PARAMS_FILE" >> "$GITHUB_OUTPUT"

- name: Validate deployment at subscription scope
run: |
az deployment sub validate \
--location "${{ env.DEPLOYMENT_LOCATION }}" \
--name "${{ env.DEPLOYMENT_NAME }}" \
--template-file dist/sovereign-lz.json \
--parameters @${{ steps.params.outputs.file }}
Comment on lines +76 to +90
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter file selection writes paths like deployments/sovereign-baseline/dev.parameters.json / prod.parameters.json, but there are no matching *.parameters.json files in the repo. This will cause az deployment sub validate/what-if/create to fail when it tries to load --parameters @...; either commit these parameter files (in this PR) or adjust the workflow to use the existing parameter file locations.

Suggested change
PARAMS_FILE="deployments/sovereign-baseline/${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/prod.parameters.json"
else
PARAMS_FILE="deployments/sovereign-baseline/dev.parameters.json"
fi
echo "file=$PARAMS_FILE" >> "$GITHUB_OUTPUT"
- name: Validate deployment at subscription scope
run: |
az deployment sub validate \
--location "${{ env.DEPLOYMENT_LOCATION }}" \
--name "${{ env.DEPLOYMENT_NAME }}" \
--template-file dist/sovereign-lz.json \
--parameters @${{ steps.params.outputs.file }}
CANDIDATE_PARAMS_FILE="deployments/sovereign-baseline/${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
CANDIDATE_PARAMS_FILE="deployments/sovereign-baseline/prod.parameters.json"
else
CANDIDATE_PARAMS_FILE="deployments/sovereign-baseline/dev.parameters.json"
fi
if [[ -f "$CANDIDATE_PARAMS_FILE" ]]; then
PARAMS_FILE="$CANDIDATE_PARAMS_FILE"
else
echo "Warning: parameter file '$CANDIDATE_PARAMS_FILE' not found; proceeding without explicit parameters." >&2
PARAMS_FILE=""
fi
echo "file=$PARAMS_FILE" >> "$GITHUB_OUTPUT"
- name: Validate deployment at subscription scope
run: |
PARAMS_FILE='${{ steps.params.outputs.file }}'
if [[ -n "$PARAMS_FILE" ]]; then
az deployment sub validate \
--location "${{ env.DEPLOYMENT_LOCATION }}" \
--name "${{ env.DEPLOYMENT_NAME }}" \
--template-file dist/sovereign-lz.json \
--parameters @"$PARAMS_FILE"
else
az deployment sub validate \
--location "${{ env.DEPLOYMENT_LOCATION }}" \
--name "${{ env.DEPLOYMENT_NAME }}" \
--template-file dist/sovereign-lz.json
fi

Copilot uses AI. Check for mistakes.

- name: Upload compiled template artifact
uses: actions/upload-artifact@v4
with:
name: bicep-compiled
path: dist/

# --------------------------------------------------------------------------
# WHAT-IF -- preview changes without applying them
# --------------------------------------------------------------------------
what-if:
name: What-If Analysis
needs: validate
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Download compiled template
uses: actions/download-artifact@v4
with:
name: bicep-compiled
path: dist/

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

- name: Determine parameter file
id: params
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/prod.parameters.json"
else
PARAMS_FILE="deployments/sovereign-baseline/dev.parameters.json"
fi
echo "file=$PARAMS_FILE" >> "$GITHUB_OUTPUT"

- name: Run what-if analysis
id: whatif
# What-if is informational; failures here must not block the pipeline.
continue-on-error: true
run: |
az deployment sub what-if \
--location "${{ env.DEPLOYMENT_LOCATION }}" \
--name "${{ env.DEPLOYMENT_NAME }}" \
--template-file dist/sovereign-lz.json \
--parameters @${{ steps.params.outputs.file }} \
> whatif-output.txt 2>&1 || true
cat whatif-output.txt

- name: Post what-if results as PR comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let whatIfOutput = fs.readFileSync('whatif-output.txt', 'utf8');

// Truncate to stay within the GitHub comment size limit.
const maxLen = 60000;
if (whatIfOutput.length > maxLen) {
whatIfOutput = whatIfOutput.substring(0, maxLen) + '\n... (truncated)';
}

const body = [
'### Sovereign Landing Zone What-If Results',
'',
'```',
whatIfOutput,
'```',
'',
'*This comment was automatically generated by the deploy-sovereign-lz workflow.*'
].join('\n');

// Remove previous what-if comments to keep the PR clean.
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});

for (const comment of comments.data) {
Comment on lines +173 to +179
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issues.listComments is called without pagination/per_page handling, so it only scans the first page of comments (default 30). On PRs with more comments, older workflow comments may not be found/deleted, resulting in duplicates. Consider requesting a higher per_page and/or paginating until the matching comment is found.

Suggested change
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
for (const comment of comments.data) {
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100
}
);
for (const comment of comments) {

Copilot uses AI. Check for mistakes.
if (comment.body.includes('Sovereign Landing Zone What-If Results') &&
comment.body.includes('deploy-sovereign-lz workflow')) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id
});
}
}

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});

# --------------------------------------------------------------------------
# DEPLOY -- apply changes to the target subscription
# --------------------------------------------------------------------------
deploy:
name: Deploy
needs: [validate, what-if]
# always() lets deploy run even when what-if fails (informational only).
# Validate must succeed, and the trigger must be a push to main or a
# non-dry-run workflow_dispatch.
if: >-
always() &&
needs.validate.result == 'success' &&
(
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
)
runs-on: ubuntu-latest
environment: sovereign-production
concurrency:
Comment on lines +211 to +215
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deploy job always runs under the sovereign-production protected environment regardless of the workflow_dispatch environment input, and it always logs into the same subscription via AZURE_SUBSCRIPTION_ID. This makes it possible to deploy a “dev” parameter set through the production gate into the production subscription. Consider mapping dev/prod to different GitHub environments and/or subscription IDs, or explicitly disallow environment: dev when running the deploy job.

Copilot uses AI. Check for mistakes.
group: sovereign-lz-deploy
# Never cancel an in-progress sovereign deployment to avoid partial
# policy assignments.
cancel-in-progress: false

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Download compiled template
uses: actions/download-artifact@v4
with:
name: bicep-compiled
path: dist/

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

- name: Determine parameter file
id: params
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/${{ github.event.inputs.environment }}.parameters.json"
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
PARAMS_FILE="deployments/sovereign-baseline/prod.parameters.json"
else
PARAMS_FILE="deployments/sovereign-baseline/dev.parameters.json"
fi
echo "file=$PARAMS_FILE" >> "$GITHUB_OUTPUT"

- name: Deploy sovereign landing zone
# Sovereign cloud modules deployed by this template:
# - modules/sovereign/policy/sovereign-policy-initiative.bicep
# - modules/sovereign/encryption/sovereign-keyvault-cmk.bicep
# - modules/sovereign/encryption/sovereign-tls-enforcement.bicep
# - modules/sovereign/confidential-compute/sovereign-confidential-vm.bicep
# - modules/sovereign/confidential-compute/sovereign-confidential-aks-nodepool.bicep
# - modules/sovereign/hybrid-arc/sovereign-arc-governance.bicep
# - modules/sovereign/identity/sovereign-rbac-assignments.bicep
Comment on lines +252 to +258
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module paths listed in the deploy-step comment don’t match the repository layout. The sovereign modules currently live directly under 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/modules/ (e.g., sovereign-policy-initiative.bicep), not under modules/sovereign/.... Please update these paths to avoid misleading operators/debugging the wrong files.

Suggested change
# - modules/sovereign/policy/sovereign-policy-initiative.bicep
# - modules/sovereign/encryption/sovereign-keyvault-cmk.bicep
# - modules/sovereign/encryption/sovereign-tls-enforcement.bicep
# - modules/sovereign/confidential-compute/sovereign-confidential-vm.bicep
# - modules/sovereign/confidential-compute/sovereign-confidential-aks-nodepool.bicep
# - modules/sovereign/hybrid-arc/sovereign-arc-governance.bicep
# - modules/sovereign/identity/sovereign-rbac-assignments.bicep
# - modules/sovereign-policy-initiative.bicep
# - modules/sovereign-keyvault-cmk.bicep
# - modules/sovereign-tls-enforcement.bicep
# - modules/sovereign-confidential-vm.bicep
# - modules/sovereign-confidential-aks-nodepool.bicep
# - modules/sovereign-arc-governance.bicep
# - modules/sovereign-rbac-assignments.bicep

Copilot uses AI. Check for mistakes.
run: |
az deployment sub create \
--location "${{ env.DEPLOYMENT_LOCATION }}" \
--name "${{ env.DEPLOYMENT_NAME }}" \
--template-file dist/sovereign-lz.json \
--parameters @${{ steps.params.outputs.file }}
Loading