|
| 1 | +--- |
| 2 | +title: Deploying Azure from GitHub |
| 3 | +description: How the deployment spec is delivered with Azure as the service provider and GitHub as the CI/CD platform, using Terraform to compute and apply the approved effect. |
| 4 | +--- |
| 5 | + |
| 6 | +# Deploying Azure from GitHub |
| 7 | + |
| 8 | +This design delivers the [Deployment spec](../spec.md) for one combination: |
| 9 | +**Azure** (including Microsoft Entra ID) as the service provider and **GitHub** |
| 10 | +as the CI/CD platform, with **Terraform (open source)** as the change |
| 11 | +engine. The computed effect the spec speaks of is a **saved Terraform plan**; |
| 12 | +the record of state is a remote Terraform state per environment. |
| 13 | + |
| 14 | +Another design covers another combination — deploying Azure from Azure DevOps, |
| 15 | +deploying AWS from GitHub, deploying GitHub from GitHub — without changing the |
| 16 | +spec. |
| 17 | + |
| 18 | +## Approach |
| 19 | + |
| 20 | +A change is proposed as a pull request. On the pull request the process computes |
| 21 | +the effect for **every environment in the promotion path** and shows them all; |
| 22 | +approving and merging the pull request is the single approval of the code change |
| 23 | +together with all of those effects ([FR2](../spec.md#fr2), [FR3](../spec.md#fr3)). |
| 24 | +The merge then runs the promotion, deploying to each environment in order using |
| 25 | +the **exact plans that were approved** ([FR4](../spec.md#fr4)), running tests |
| 26 | +between environments ([FR7](../spec.md#fr7)) and recording every action |
| 27 | +([FR9](../spec.md#fr9)). |
| 28 | + |
| 29 | +```mermaid |
| 30 | +flowchart TB |
| 31 | + subgraph Review [Pull request — one approval] |
| 32 | + C[Change as code] --> P[Plan per environment\ndev · test · preprod · prod] |
| 33 | + P --> R[All plans + tests shown in the PR] |
| 34 | + end |
| 35 | + R -->|team approves code + all effects, merges| P0 |
| 36 | + subgraph Promote [Merge — promotion using approved plans] |
| 37 | + P0[Apply approved dev plan] --> T0[Automated tests] --> P1[Apply approved test plan] |
| 38 | + P1 --> T1[Automated tests] --> P2[Apply approved preprod plan] |
| 39 | + P2 --> T2[Automated tests] --> P3[Apply approved prod plan] |
| 40 | + end |
| 41 | + P3 --> Rec[Every action recorded] |
| 42 | + P0 -.effect no longer valid.-> Stop[Stop · recompute · re-approve] |
| 43 | +``` |
| 44 | + |
| 45 | +### Why this shape |
| 46 | + |
| 47 | +- **One approval for the whole path.** Because the reviewer sees the effect on |
| 48 | + every environment, a single approval is sufficient; the design adds no |
| 49 | + per-environment reviewer gate, matching the spec's non-goal. |
| 50 | +- **Approved plans, not re-derived ones.** The saved plans reviewed on the pull |
| 51 | + request are the artifacts the promotion applies. This trades the convenience of |
| 52 | + re-planning at deploy time for the guarantee the spec requires — the deployed |
| 53 | + effect equals the approved effect. |
| 54 | + |
| 55 | +### Alternatives considered |
| 56 | + |
| 57 | +- **Re-plan at deploy.** Rejected: the deploy would compute its own effect, which |
| 58 | + can differ from the approved one — it fails [FR4](../spec.md#fr4). |
| 59 | +- **Approve only the source diff, plan at deploy.** Rejected: the reviewer never |
| 60 | + sees the effect, failing [FR2](../spec.md#fr2)–[FR3](../spec.md#fr3). |
| 61 | +- **A required-reviewer gate on each environment.** Rejected as redundant: the |
| 62 | + effects for all environments are already approved together; extra gates add |
| 63 | + friction without adding a decision. |
| 64 | + |
| 65 | +## Components |
| 66 | + |
| 67 | +| Component | Role | |
| 68 | +| --- | --- | |
| 69 | +| **Reusable workflow** (`.github/workflows/terraform.yml`, `workflow_call`) | The provider-agnostic core: init, format, validate, plan, render, policy scan, test, apply. | |
| 70 | +| **Plan (pull-request) workflow** | For every environment in the path, computes `plan -out`, renders it into the pull request, scans it, and uploads it as the approved artifact. | |
| 71 | +| **Promotion (merge) workflow** | Walks the environments in order, applying each approved plan and running tests between them. | |
| 72 | +| **Azure identity step** | `azure/login` federated sign-in; sets `ARM_USE_OIDC=true` so the `azurerm` and `azuread` providers use the same token. | |
| 73 | +| **State backend** | Azure Storage per environment, with blob-lease locking and encryption at rest — the record of state. | |
| 74 | +| **Environments** | GitHub Environments per environment name — the per-environment **identity boundary** and deployment branch policy (not an approval gate). | |
| 75 | +| **Break-glass workflow** (`workflow_dispatch`) | The emergency path. | |
| 76 | +| **Drift job** (`schedule`) | Periodic `plan -detailed-exitcode` that opens an issue on drift. | |
| 77 | + |
| 78 | +## The effect is a saved plan |
| 79 | + |
| 80 | +Terraform's saved plan is what makes "approve the effect, then apply exactly it" |
| 81 | +concrete: |
| 82 | + |
| 83 | +1. `terraform plan -out=tfplan.<env>` computes the effect against that |
| 84 | + environment's state and writes it to a file that pins provider versions and |
| 85 | + the state's **serial** and **lineage**. |
| 86 | +2. `terraform show` renders it for humans (pull-request comment) and as JSON for |
| 87 | + policy scanning; `sha256(tfplan.<env>)` is recorded. |
| 88 | +3. `terraform apply tfplan.<env>` carries out **exactly** that effect, and |
| 89 | + refuses with `Saved plan is stale` if the state advanced — the native backstop |
| 90 | + for [FR5](../spec.md#fr5). |
| 91 | + |
| 92 | +## Review and approval |
| 93 | + |
| 94 | +The pull-request workflow computes a plan for **each** environment in the |
| 95 | +promotion path and makes all of them visible in the pull request, alongside |
| 96 | +format, validation, policy, and test results. These are **required checks**, so a |
| 97 | +change cannot merge unless every environment's effect computed successfully. A |
| 98 | +branch ruleset on `main` requires review (CODEOWNERS for `**/*.tf`), dismisses |
| 99 | +stale approvals on new commits, and requires branches to be up to date — so the |
| 100 | +shown effects reflect current `main`. Approving and merging is the single |
| 101 | +approval the spec requires ([FR3](../spec.md#fr3)); the approved plan artifacts |
| 102 | +are retained for the promotion. |
| 103 | + |
| 104 | +## Promotion using the approved plans |
| 105 | + |
| 106 | +On merge, the promotion workflow retrieves the plan artifacts approved on the |
| 107 | +pull request (keyed to the merge commit) and, for each environment in order: |
| 108 | + |
| 109 | +1. **Verifies** `sha256(tfplan.<env>)` against the value recorded at plan time, |
| 110 | + defeating artifact swap. |
| 111 | +2. **Applies** `terraform apply tfplan.<env>` — exactly the approved effect |
| 112 | + ([FR4](../spec.md#fr4)). If the plan is stale, the apply fails closed and the |
| 113 | + promotion stops; a fresh plan is produced and re-approved |
| 114 | + ([FR5](../spec.md#fr5)). |
| 115 | +3. **Tests** — runs the environment's automated tests; a failing required test |
| 116 | + stops the promotion before the next environment ([FR7](../spec.md#fr7)). |
| 117 | + |
| 118 | +Deployments against the same state are **serialised** by a concurrency group |
| 119 | +keyed per state with `cancel-in-progress: false`, backed by the state lock, and |
| 120 | +an in-flight apply is never cancelled ([FR6](../spec.md#fr6)). |
| 121 | + |
| 122 | +## Identity and access (OIDC) |
| 123 | + |
| 124 | +- The workflow requests the GitHub OIDC token with `permissions: id-token: write`; |
| 125 | + `azure/login` federates on it using the non-secret `client-id`, `tenant-id`, |
| 126 | + and `subscription-id` — no client secret exists ([NFR1](../spec.md#nfr1)). |
| 127 | +- Azure federated credentials pin the subject claim |
| 128 | + `repo:MSXOrg/Platform:environment:<env>`, binding identity to the environment |
| 129 | + ([NFR3](../spec.md#nfr3)). |
| 130 | +- Two identities per environment: **read-only** for computing the effect and |
| 131 | + **write** for applying it, the write identity reachable only from that |
| 132 | + environment ([NFR2](../spec.md#nfr2)). |
| 133 | +- Entra ID resources use the `azuread` provider on the same federated token — |
| 134 | + Entra rides the Azure identity context, no separate secret. Managing GitHub |
| 135 | + itself uses the `github` provider with a GitHub App installation token, never a |
| 136 | + personal access token. |
| 137 | + |
| 138 | +## Break-glass path |
| 139 | + |
| 140 | +The emergency path ([FR8](../spec.md#fr8)) is a `workflow_dispatch` "emergency |
| 141 | +deploy" with required inputs `reason`, `incident_id`, target environment, and a |
| 142 | +typed confirmation, restricted to authorised operators. It still runs |
| 143 | +`plan -out` then `apply tfplan`, so the effect is shown before it deploys; it |
| 144 | +records the inputs and logs as an immutable run record, and opens a follow-up |
| 145 | +issue to bring the change back through the standard pull-request path. There is |
| 146 | +no flag on the standard workflow that skips review. |
| 147 | + |
| 148 | +## Records and drift |
| 149 | + |
| 150 | +Every run's rendered plans, apply logs, and test results are retained as run |
| 151 | +artifacts and logs, and each apply is recorded in the environment's deployment |
| 152 | +history — the audit trail for [FR9](../spec.md#fr9)/[NFR4](../spec.md#nfr4). A |
| 153 | +scheduled `plan -detailed-exitcode` job detects drift and opens an issue |
| 154 | +([FR10](../spec.md#fr10)). |
| 155 | + |
| 156 | +## Interface |
| 157 | + |
| 158 | +```yaml |
| 159 | +# .github/workflows/terraform.yml (reusable — workflow_call) |
| 160 | +on: |
| 161 | + workflow_call: |
| 162 | + inputs: |
| 163 | + environment: { type: string, required: true } # dev | test | preprod | prod |
| 164 | + working-directory: { type: string, required: true } |
| 165 | + action: { type: string, default: plan } # plan | apply |
| 166 | + terraform-version: { type: string, default: "1.9.x" } |
| 167 | +``` |
| 168 | +
|
| 169 | +The core is provider-agnostic; the Azure sign-in is the single service-provider |
| 170 | +step. A different service provider (AWS) or CI/CD platform (Azure DevOps) |
| 171 | +reuses this design's shape with its own identity step and is documented as its |
| 172 | +own design under [Deployment](../index.md). |
| 173 | +
|
| 174 | +## Security boundaries and threats |
| 175 | +
|
| 176 | +- **Credential exposure** — OIDC only; no static cloud secret exists, and the |
| 177 | + default token is read-only. |
| 178 | +- **Plan artifact confidentiality** — a saved plan can contain sensitive values |
| 179 | + in plaintext. Retention is short, artifacts are restricted, sensitive variables |
| 180 | + and outputs are marked, and pull-request comments carry scrubbed renders; on a |
| 181 | + public repository plan artifacts are never exposed unscrubbed. |
| 182 | +- **Artifact swap** — each apply verifies `sha256(tfplan.<env>)` before applying. |
| 183 | +- **Cross-environment escalation** — federated subjects are pinned per |
| 184 | + environment; the write identity is reachable only from its environment. |
| 185 | +- **Stale apply** — Terraform's serial/lineage guard, plus serialisation and |
| 186 | + branch-freshness rules. |
| 187 | + |
| 188 | +## Testing and operability |
| 189 | + |
| 190 | +- **Dry run** the full path on `dev`: pull-request plans for every environment, |
| 191 | + review, merge, promotion with tests. |
| 192 | +- **Exercise the stale path** by mutating state between approval and deploy and |
| 193 | + confirming the apply fails closed. |
| 194 | +- **Exercise break-glass** end-to-end and confirm the reconciliation follow-up is |
| 195 | + opened and attribution is recorded. |
| 196 | + |
| 197 | +## Repository hardening |
| 198 | + |
| 199 | +Third-party actions are pinned to commit SHAs; allowed actions are restricted; |
| 200 | +secret scanning with push protection is enabled as defence in depth even under an |
| 201 | +OIDC model. |
| 202 | + |
| 203 | +## Decisions |
| 204 | + |
| 205 | +One-way-door decisions are recorded here beside the spec, per |
| 206 | +[Decision before change](../../../Ways-of-Working/Principles/AI-First-Development.md#decision-before-change). |
| 207 | + |
| 208 | +- **Approve every environment's effect on the pull request; apply those exact |
| 209 | + saved plans on merge.** Chosen over re-plan-at-deploy (does not apply the |
| 210 | + approved effect) and per-environment gates (redundant given the effects are |
| 211 | + already approved together). Revisit only if a saved plan can no longer be the |
| 212 | + review-to-deploy contract. |
| 213 | + |
| 214 | +## References |
| 215 | + |
| 216 | +- GitHub — security hardening with OpenID Connect: <https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect> |
| 217 | +- GitHub — configuring OIDC in Azure: <https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-azure> |
| 218 | +- GitHub — managing environments: <https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments> |
| 219 | +- GitHub — reusing workflows: <https://docs.github.com/en/actions/using-workflows/reusing-workflows> |
| 220 | +- GitHub — control concurrency: <https://docs.github.com/en/actions/using-jobs/using-concurrency> |
| 221 | +- HashiCorp — automate Terraform with GitHub Actions: <https://developer.hashicorp.com/terraform/tutorials/automation/github-actions> |
| 222 | +- HashiCorp — `terraform plan` / `terraform apply` (saved plan): <https://developer.hashicorp.com/terraform/cli/commands/plan> · <https://developer.hashicorp.com/terraform/cli/commands/apply> |
| 223 | +- Terraform AzureRM provider — OIDC: <https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/service_principal_oidc> |
| 224 | +- Azure — `azure/login`: <https://github.com/Azure/login> |
| 225 | +- Microsoft — GitHub Actions + Terraform OIDC on Azure sample: <https://learn.microsoft.com/en-us/samples/azure-samples/github-terraform-oidc-ci-cd/github-terraform-oidc-ci-cd/> |
0 commit comments