Repository navigation
Implement a GitHub action to provision a new sandbox environment #173
Description
Current progress (2026-10-20)
This issue remains open. Both deployment scenarios have successful live acceptance, but operational follow-up remains. This section supersedes conflicting older plans below, particularly trigger selection, state-key reuse, automatic teardown, job reruns, and the identity/authentication target described below.
Delivered and verified
| Area | Progress |
|---|---|
| Manual new-sandbox deployment | Run 38492739205 completed Preflight, Apply, Unit-test, and JSON publication successfully: 109 passed, 0 failed, with 10 optional suites skipped under existing discovery behavior. |
| Release-PR deployment gate | PR #848 exercised draft skipping, ready-for-review triggering, pending status before approval, protected-environment approval, exact-SHA checkout, full deployment/testing, and final status publication. Run 38591955844 passed 109 checks, 0 failed on candidate b12279415b156d6a46f33f607ed4bcaabc1da964. The self-hosted job took 1h48m37s. |
| Evidence | Both accepted runs published a single ZIP entry, unit-test-results.json, with schema 1, result pass, exit code 0, and consistent totals. No raw logs or XML are published by this workflow. |
| Merge enforcement | The active main-release ruleset now requires main-source-eligibility, sandbox-deployment-gate, and Publish release deployment status, all from GitHub Actions. Ordinary merges are blocked by failed checks; PRs are not automatically closed. The explicit owner-only PR maintenance bypass remains. |
| Release | #848 was merged with a regular merge commit preserving branch lineage. v5.14.2 points to that merge, 180b468333ed5c7dbcceb409fec93e54057af8a8. |
| Documentation and latest enhancement | #849 synchronized main-only policy/history into vnext; #850 published the complete sanitized walkthrough, current enforcement guidance, and actual resource-group identification in future summaries. vnext is now 6f39047b698318cd0b6f94c862c39433a6911e52; these post-release changes are not yet on main or in v5.14.2. All applicable PR and post-merge CI passed. |
The backend configuration, manually authored tfvars delivery, explicit Azure CLI/PowerShell managed-identity login, provision/apply sequence, VM-start gate, test/evidence publication, shared deployment concurrency, trusted PR-entry guards, and repository/environment setup are implemented for the currently agreed scope. The guides live under extras/configurations/rg-devops-iac/.
Identity: approval-gated, secretless OIDC with JIT authorization (#500 / #501)
The accepted runs above used the earlier VM-attached managed-identity path. #500's OIDC deployment identity is implemented in vnext (#903), and live acceptance is in progress through release PR #904. #501 is still follow-up design. It supersedes older statements that OIDC is only for hosted runner startup, that provisioning must use the VM's attached deployment identity, that #500 implements PIM-for-Groups activation, or that the deployment identity keeps standing Owner.
- Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500 owns deployment OIDC and JIT authorization. GitHub Actions workload identity federation goes to a dedicated deployment user-assigned managed identity (UAMI) that is not attached to the runner VM. Terraform and unit/integration tests stay on the self-hosted runner for network access. OIDC changes authentication, not execution location.
- No standing deployment privilege. The OIDC UAMI holds Reader between runs. Before approving a run, the operator grants the UAMI time-bound active Contributor + ABAC-constrained Role Based Access Control Administrator, which Azure expires server-side. The grant uses
extras/configurations/rg-devops-iac/scripts/grant-jit-roles.sh(default 8 hours; it activates the operator's eligible Owner through PIM when needed; committed on the Workflow-driven start/stop for the rg-devops-iac runner VM #501 branch, not yet merged) or the portal. PIM can take about 5 minutes to list a new grant, so the operator waits for it before approving. After OIDC login, the workflow preflight polls the caller's effective permissions and fails fast if the grant is missing or less thanSANDBOX_JIT_MIN_REMAINING_MINUTES(default 150) remains. This requires Microsoft Entra ID P2 or ID Governance. - Human approval remains mandatory. Both
sandbox-deploymentandsandbox-release-gaterequire protected-environment approval before a job obtains its OIDC identity. Preserve trusted-source validation, branch restrictions, release status enforcement, and serialized execution. - Federated trust is scoped by environment. Federated subjects follow the repository's custom OIDC subject template (
repository_owner_id,repository_id,context), so they pin the repository and environment by immutable id but not the workflow file. Environments restricted tomain, required reviewers, and the workflow's trusted-entry guards compensate. Only the self-hostedsandboxjob getsid-token: write, and Workflow-driven start/stop for the rg-devops-iac runner VM #501's lifecycle identity must use its own environment. No Azure client secret, certificate, storage key, or stored bearer token is introduced. - No Owner in any mode. Contributor + constrained RBAC Admin, which can assign only the sandbox's data-plane roles, replaces Owner. Microsoft Graph uses
Group.Create+GroupMember.Read.All+User.Read.All+Application.Read.Allinstead ofGroup.ReadWrite.All. Delivered forclient_secretandmanaged_identityin refactor!: unify Terraform authentication contract and least-privilege deployment identity #871 (released in Release: unified auth contract and least-privilege deployment identity #874); a from-scratch apply passed, and a full destroy is still untested.oidcmust follow the same model. - Authentication contract refactor (breaking): delivered in refactor!: unify Terraform authentication contract and least-privilege deployment identity #871. The root sandbox uses
auth_mode = client_secret | managed_identity, derived once inlocal.provider_authforazurerm,azapi, andazuread, and the root README has one canonical Authentication section.rg-devops-iacusesdeployment_identity = none | managed_identity. Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500 (Secretless OIDC deployment identity with just-in-time roles (#500) #903) addedoidcto both, keeping a VM-attached UAMI and the OIDC UAMI mutually exclusive. - Two separate authentication phases; don't conflate them.
- Provisioning
rg-devops-iac(operator): applying therg-devops-iacconfiguration, normally the initial apply of a new environment, uses only the operator's Azure CLI sign-in (use_cli = true). refactor!: unify Terraform authentication contract and least-privilege deployment identity #871 removed its service principal mode so there is no second standing high-privilege SPN. The operator holds standing Reader and activates a PIM-eligible Owner for this apply (rg-devops-iac: replace jumplinux2 public IP with Bastion Standard + Entra ID SSH (secretless) #892). - Sandbox deployments from the deployed environment (no operator credentials): the self-hosted runner on
jumplinux2deploys the root Azure Sandbox with the rootauth_mode:client_secret,managed_identity(the VM-attached UAMI, still supported for terminal use), oroidc(the unattached UAMI with JIT elevation, Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500). The workflow is OIDC-only. The state backend uses the VM's system-assigned MSI. The operator approves runs and, foroidc, grants the JIT elevation, but their own Owner isn't used. - The three authentication patterns apply to Sandbox deployments, not to provisioning
rg-devops-iac.
- Provisioning
- Backend stays on host MSI. The VM's system-assigned identity keeps
use_azuread_auth = true/use_msi = trueand its narrowly scoped state-storage access. It gets no subscription Owner or Graph write.rg-devops-iacremains a multipurpose Terraform host, and backend access from an ordinary terminal continues without GitHub. No state migration. - Workflow-driven start/stop for the rg-devops-iac runner VM #501 uses a separate lifecycle identity. A minimal OIDC identity (VM read, start, and deallocate only) runs from GitHub-hosted jobs that start the runner VM before deployment and deallocate it after everything has quiesced.
- Add GitHub App authentication mode for self-hosted runner registration #793 (GitHub App mode) is orthogonal. It authenticates only to the GitHub API for runner registration and is not an Azure authentication alternative.
Completion requires:
- positive approval-gated deployment/test acceptance inside the JIT window (in progress through Release: secretless OIDC deployment identity with just-in-time roles #904; OIDC sign-in and JIT preflight have passed live)
- long-run token renewal (implemented: a fresh GitHub ID token for each Azure CLI/Az PowerShell sign-in, per stage and per test suite; the Terraform providers request their own with
use_oidc) - negative checks for unapproved or unauthorized subjects, a missing or expired JIT grant, and VM-side access to OIDC UAMI tokens
A persistent self-hosted runner still needs hardening. Neither OIDC nor JIT protects an approved job from code already running on its host.
Design and acceptance details are tracked in #500. Lifecycle integration is tracked in #501. This follows CAF DevOps identity guidance, Azure Login with UAMI OIDC federation, and time-bound Azure RBAC assignments.
Current operating decisions
- The registered workflow is
.github/workflows/sandbox-deployment.yml. Manual dispatch retains its stage selector andsandbox-deploymentenvironment, and itssource_branchchoice deploys this repository'svnext(default) ormainbranch tip. Any other value is rejected before checkout or environment-secret access (Azure Sandbox deployment: allow manual deployment from vnext or main #853; Allow manual sandbox deployment from vnext or main #868, registered onmainby Register manual deployment source choice on main (#853) #869, synced by Merge main into vnext to record maintenance PR #869 history #870). Release PRs ignore it, and manual runs never publish release statuses. Release PRs use trustedpull_request_targetentry for same-repositoryvnexttomain, exact candidate checkout, andsandbox-release-gate. Drafts and ordinary title/description edits do not deploy. - Both environments require human approval. Protected
SANDBOX_BACKEND_HCLandSANDBOX_TFVARS_HCLsupply environment configuration; values are not committed. The approved automated scope enables all base modules and excludes extra modules. - Applies are serialized through the shared deployment-job concurrency group with active-run cancellation disabled. Hosted metadata-only validation does not occupy that deployment lock.
- The operational model is new-run-only: each fresh workflow run has a new deployment ID and remote state key; preflight rejects an existing state blob. GitHub job reruns, per-attempt state isolation, incremental updates, and automatic recovery are not supported. No explicit run-attempt rejection guard has been added.
- Azure teardown and backend-state cleanup are manual. There is no automatic teardown on success and no implemented
keep_sandbox/clean_previous_runlifecycle. These replace the older cleanup proposals below. - Raw diagnostics, plans, and configuration remain private on the runner. Show deployed resource group and consolidate release-gate documentation #850 captures
resource_names.resource_groupafter successful apply and reports only that name as an operator-approved public exception; other resource names/IDs remain private. This enhancement has isolated validation but has not yet been exercised by a subsequent live deployment, and it does not rewrite old summaries.
Remaining work / verification gaps
- Bounded runner-local cleanup remains deferred: private per-run configuration, plans, logs, and Terraform data/provider downloads persist outside the checkout. Checkout's post-job cleanup is not a retention solution. Preserve active runs when implementing cleanup.
- Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500 remains open: the authentication contract refactor and the removal of Owner and
Group.ReadWrite.Allfor SPN and MSI are done (refactor!: unify Terraform authentication contract and least-privilege deployment identity #871). Theoidcmode, the unattached deployment UAMI with standing Reader, the federated credentials and the JIT preflight are implemented invnext(Secretless OIDC deployment identity with just-in-time roles (#500) #903). Still to do: live release acceptance (Release: secretless OIDC deployment identity with just-in-time roles #904), merging thegrant-jit-roles.shhelper, negative checks, a least-privilege full-destroy test, and identity-boundary verification. Close Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500 manually after release. - Workflow-driven start/stop for the rg-devops-iac runner VM #501 remains open: workflow-driven runner VM start/readiness and post-run deallocation, using a separate narrowly scoped OIDC lifecycle identity, remain follow-up. Until implemented, the operator must ensure the runner is online and stays running throughout deployment.
- Live acceptance covered the successful release path. No deliberate live test-failure, cancellation, or status-API-failure acceptance run was performed; isolated publisher/evidence tests covered failure handling. Additional reporting features from older plans, such as per-module summary tables and per-check annotations, should not be treated as delivered by the aggregate stage/count summary and JSON-only artifact.
No additional Azure deployment or cleanup was started during branch consolidation. Local publication branches/worktrees were removed only after preserving and verifying their changes; the checkout is clean with local main and vnext matching their remotes.
Historical design and planning (retained for traceability)
The sections below predate the progress update above. Their proposed inputs, lifecycle, and todo descriptions are historical where they conflict with the current operating decisions.
Two-scenario strategy (2026-10-08)
The CD design supports two scenarios, delivered in order. Scenario 1 is implemented and fully tested end to end before any Scenario 2 work starts. Where older text below assumes CD is only the merge gate, this section takes precedence.
| Scenario 1: automated new-sandbox deployment (first) | Scenario 2: vnext → main PR merge gate (second, builds on 1) |
|
|---|---|---|
| Purpose | Deploy a new sandbox from the self-hosted runner without manual setup | Prove a release candidate deploys and passes tests before merging to main |
| Trigger | workflow_dispatch only (chosen ref, module toggles, tags) |
pull_request targeting main + workflow_dispatch re-run |
| Lifecycle | Sandbox is kept by default; teardown is a separate, explicit action | Ephemeral; torn down on success (keep_sandbox = false default) |
| Backend state | One azurerm state key per sandbox so several can coexist |
One fixed gate key, left empty after each run |
| Tests | Post-apply VM-start gate + Invoke-UnitTests.ps1; results reported (summary, annotations, artifacts) |
Same, and the job result is a required status check on main |
| Repo setup | sandbox-cd Environment with required reviewer |
Plus branch protection, same-repo/fork guard and required check |
Shared building blocks (needed by both, delivered with Scenario 1): runner (#499), managed-identity auth (#496), user-assigned identity with subscription Owner on the runner (#797), backend config, tfvars input, Azure login on the runner, apply, tests and result reporting, concurrency guard. Scenario 2 then adds the PR trigger, fork/same-repo guard, required status check and automatic teardown.
#500 (PIM JIT) and #501 (runner auto-shutdown + preflight start) are not required for either scenario's first end-to-end test; until #501 lands, confirm jumplinux2 is running before dispatching.
Current architecture correction (2026-09-21)
The current CD design uses a GitHub-hosted OIDC identity only for the narrowly scoped preflight that starts jumplinux2. Terraform and the complete test suite run on the self-hosted runner with managed identity; #496 therefore supports secret (default) and msi provider modes only, not OIDC.
The runner-local vWAN/P2S test retrieves its client credential from the sandbox Key Vault after the apply barrier disables public access. A direct GitHub-hosted Terraform/test path is out of scope until it has a separate secure P2S-credential and network design. The text below is retained as history; where it conflicts with this section, this section controls.
Implement a GitHub Action that provisions a new sandbox environment as an automated gate for merging vnext into main.
Terminology
"CD" in this issue (and its sub-issues #496–#501) means an ephemeral plan/apply validation gate, not maintenance of a persistent environment. The pipeline provisions a throwaway sandbox from vnext, runs the full unit/integration test suite against that live deployment, and reports pass/fail as a required status check on the vnext → main release PR. A human still performs the actual release (the merge). On success the sandbox is normally torn down (keep_sandbox = false default) — there is no long-lived environment this pipeline keeps continuously up to date. Conceptually this maps to classic Continuous Delivery (every change is automatically proven release-ready; a human still releases), implemented here as an ephemeral plan/apply smoke test rather than a persistent-environment sync.
Problem
The repo today is built for interactive/manual execution only. Several aspects of the current architecture block a headless CD process and must be resolved first (see Blockers).
Approach
Build a GitHub Actions workflow that automates the non-interactive equivalent of the copilot-instructions Scenario 1 (fresh provision) plus the post-apply VM-start gate + Invoke-UnitTests.ps1, gating the run on the unit-test result (exit 0 = pass / 1 = fail). It is the final release gate on the vnext → main PR — it runs automatically on pull_request targeting main (plus workflow_dispatch for manual re-runs) and publishes a required status check in main branch protection, so the merge into main fails unless CD passes. It runs on a self-hosted runner on the rg-devops-iac Linux VM using that VM's managed identity (no stored secrets). Terraform state is always held in the azurerm remote backend (the rg-devops-iac tfstate storage account, Azure AD / managed identity auth). Runner-local .tfstate is not used.
Decisions captured
- State backend (decided — remote backend always, see Design update comment): CD always uses the
azurermbackend in therg-devops-iactfstate storage account withuse_azuread_auth = true/use_msi = true. Runner-local state is rejected as an anti-pattern: no blob-lease locking, no versioning/soft-delete/backup, state (with secrets) at rest on a CI host, and no shared source of truth if the runner is rebuilt. Consequences:actions/checkoutcan keep its defaultclean: true, and loss of the runner VM no longer orphans resources. The only configuration that legitimately uses local state is the bootstrap apply ofrg-devops-iacitself, which creates the storage account and therefore cannot store state in it; that apply runs from a separate execution environment, not from the runner. - Auth / runner: self-hosted runner on the
rg-devops-iacTerraform-execution VM using its managed identity (Owner on the subscription + Graph perms). No long-lived secrets. - Dual-auth prerequisite (→ Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496):
providers.tf+variables.tfcurrently hardcode/require SPN client-secret auth, blocking MSI/OIDC. Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496 adds a selectable auth mode (secretdefault |msi|oidc) acrossazurerm,azapi,azuread, makingarm_client_secretconditional while keeping the current manual secret flow working unchanged. Bothmsiandoidcare needed —msifor the self-hosted provisioning job (network-isolated KV/Storage access via the VM's identity),oidcfor the GitHub-hosted preflight job (narrowly-scoped, ARM-control-plane-onlyaz vm start, no VM identity available). Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496 must land before the CD workflow can use managed identity. Status: completed in Add managed identity authentication mode for Terraform providers #743 — implemented assecret|msionly (OIDC excluded per the correction at the top of this issue). - Headless admin identity:
user_object_id/user_namegrant a human RBAC + AAD group membership; these are non-secret identifiers. The workflow takes the admin UPN as aworkflow_dispatchinput and resolvesuser_object_idat runtime via Microsoft Graph (az ad user show --id <upn>) using the runner's managed identity. - Non-interactive tfvars (supplied, not generated):
terraform.tfvarsis authored manually and supplied to the workflow — either as a complete file or as discrete inputs assembled by the workflow, depending on how the remaining sub-issues are implemented.bootstrap.sh/bootstrap.ps1remain interactive helpers for the manualsecret-auth flow only and are not part of the CD path, so no headless bootstrap mode is required (Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497 closed as not planned). The supplied values must coverarm_auth_mode = "msi",arm_client_id(the managed identity client id, required in MSI mode),user_object_idfor the human admin (the CD identity is not the admin, so it cannot be derived from the signed-in token), and the module toggles. - Public-access barrier (not invoked for Scenario 1): the barrier disables KV/Storage public access at the end of an apply. On a fresh Scenario-1 apply (empty state) the resources are created with public access enabled, written to, then disabled — all within the single apply — so no
enable-public-access.shstep is needed. Because CD is from-scratch only (the run destroys any prior sandbox before applying, so the backend state is empty at apply time), the Scenario-2 re-apply path and itsenable-public-access.shstep never arise in CD. CD is explicitly not used for incremental deployments. - Post-apply tests + result surfacing: run the VM-start gate (
scripts/manage-vms.sh start+ confirmVM running) thenInvoke-UnitTests.ps1(all installed modules; unit+integration auto-run), authenticating viaConnect-AzAccount -Identity. "Test-result gating" means all of:- Job gating — fail the job on non-zero exit (
1= test failures,2= harness error). - Job Summary — parse per-module summary/overall lines into a Markdown results table written to
$GITHUB_STEP_SUMMARY(✅/❌ per module, Passed/Failed counts, overall). - Annotations — emit
::error::for each failed module (and, where available, each failed[FAIL]check). - Artifact — upload the full test log (and structured results if produced) via
actions/upload-artifact.
- Per-check precision (→ Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498): delivered by adding machine-readable output (JSON/JUnit) to
Invoke-UnitTests.ps1, consumed by theworkflow-testsstep. Status: completed in Emit machine-readable test results (JSON + JUnit XML) from Invoke-UnitTests.ps1 #790 —-ResultsJsonPath/-JUnitXmlPathnow available; per-check precision is no longer blocked.
- Job gating — fail the job on non-zero exit (
- Trigger / merge gate (
vnext → mainrelease PR) — including security hardening: the workflow triggers onpull_requestwithbranches: [main](opened/synchronize/reopened — thevnext → mainrelease PR) plusworkflow_dispatchfor manual re-runs. It is not run on the many day-to-day PRs targetingvnext. The CD job publishes a status check configured as a required status check inmainbranch protection, so a merge intomainis blocked unless CD passes. Security requirements, since this runs on a self-hosted runner in a public repo:- Explicit same-repo/head-ref guard on the provisioning job — do not rely on branch-protection/merge-approval alone, since a naive trigger would schedule a job on the runner for any
pull_requestopened againstmain, including one from a fork, regardless of whether it would ever be merged. Guard with e.g.if: github.event.pull_request.head.repo.full_name == github.repository. - Mandatory, non-bypassable Environment required-reviewer approval gate on the provisioning job — including for
workflow_dispatchruns. This pauses the job before it touches the runner until a human approves; the standard mitigation for self-hosted runners on public repos. - Being the sole approver of the
vnext → mainmerge is not sufficient by itself — it doesn't stop a workflow from being triggered, and the actual code executed by CD is whatever is already merged intovnext(so PR-review diligence on everyvnext-bound change, especially to workflows/scripts/Terraform, remains the primary control). - Branch protection + the Environment are repo-admin settings (documented setup, not code). New PR commits (
synchronize) re-run the check; the single runner/state serializes runs.
- Explicit same-repo/head-ref guard on the provisioning job — do not rely on branch-protection/merge-approval alone, since a naive trigger would schedule a job on the runner for any
- Sandbox lifecycle / cleanup (
keep_sandboxinput, defaultfalse): acts only on a passing run (a failed run retains everything for debugging):keep_sandbox = false(default) → auto-clean on success: (1) delete the sandbox resource group viaaz group delete -n <sandbox-rg> --yes --no-wait(notterraform destroy— fast, avoids destroy-ordering issues); RG name read fromterraform output -json resource_namesbefore deletion; (2) clean the runner working tree via./scripts/cleanterraformtemp.sh(removesterraform.tfvars,terraform.tfstate*,.terraform/, lock files, SSH keys,*.pem/.pfx/.log/.tfplan).keep_sandbox = true→ no cleanup; sandbox RG and runner config/state preserved.- State-location reconciliation: no longer applicable — state lives in the remote backend, not the workspace.
cleanterraformtemp.shstill clearsterraform.tfvars,.terraform/, SSH keys and plan files from the runner working tree, and teardown must also leave the backend state empty (terraform destroy, or RG delete followed by state removal) so the next run starts from scratch.
- Preflight leftover-config check +
clean_previous_runinput: a prior run (especiallykeep_sandbox = true, or an aborted/failed run) can leaveterraform.tfvars/.terraform/on the runner and non-empty remote backend state. The preflight must check both. The preflight job detects leftover config and honorsclean_previous_run(defaultfalse):truerunscleanterraformtemp.shfirst;falsewith leftover found fails fast with guidance. - Scope (agreed): Scenario 1 + post-apply VM-start gate + gated tests +
keep_sandboxcleanup. (Scenario 2/3 reuse and failure-auto-issue are out of scope this iteration — see Open items.)
Blockers (do not start the CD workflow until cleared)
Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496 dual-auth refactor— ✅ DONE. Landed in Add managed identity authentication mode for Terraform providers #743, merged tovnexton 2026-09-22. Addsarm_auth_mode(secretdefault |msi) acrossazurerm,azapi, andazuread. Both modes validated end to end against the same module set: 192 resources added and 41 of 41 tests passing in each. No longer a blocker.Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497 headless bootstrap— REMOVED, not a blocker.terraform.tfvarsis authored manually and supplied to the workflow (in whole or in parts), so the CD path has no dependency on the bootstrap scripts. They remain interactivesecret-mode helpers. Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497 closed as not planned.Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498 machine-readable test output (JSON/JUnit)— ✅ DONE. Landed in Emit machine-readable test results (JSON + JUnit XML) from Invoke-UnitTests.ps1 #790, merged tovnexton 2026-10-05. Adds optional-ResultsJsonPath/-JUnitXmlPathtoInvoke-UnitTests.ps1with per-check results, and fixes exit codes so failures return non-zero underpwsh -File. No longer a blocker.- Optionally register the rg-devops-iac VM as a GitHub Actions self-hosted runner #499 self-hosted runner in
rg-devops-iac— register the runner declaratively as an opt-in part of therg-devops-iacVM provisioning (cloud-init installs/registers theactions/runneragent as a service, toolchain present, uses the VM managed identity, token via Key Vault). Default off. - Managed-identity RBAC (two selectable paths, via
rbac_modeinput): the apply writes role assignments and creates/populates AAD groups, so during the apply window the runner identity needs Owner (or User Access Administrator + Contributor) at subscription scope — Reader alone cannot apply.- Option A — standing Owner (default): assign the runner MI a permanent subscription Owner role. Lowest complexity; consistent with the manual SPN flow today; long-lived privileged CI identity. Fine for a throwaway sandbox subscription, but large blast radius.
- Option B — PIM JIT elevation Reader → Owner with human approval (→ Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500): MI holds only standing Reader; Owner is activated just-in-time per run (PIM for Groups: MI is a member of a role-assignable group eligible for Owner; a workflow step requests activation via the Graph PIM API, an approver approves, then it deactivates). Requires Entra ID P2. Smallest blast radius; adds approval-gate + activate/deactivate steps.
- Caveat (both modes): the Graph app permissions (
User.Read.All, group create/membership for themssqlsql_adminsgroup) are app-role grants on the MI and remain standing, scoped least-privilege — not PIM-JIT'd like Azure RBAC.
- Workflow-driven start/stop for the rg-devops-iac runner VM #501 runner-VM auto-shutdown + workflow-driven start:
rg-devops-iaccurrently has no power-state lifecycle management for the runner VM, so it would run indefinitely and leak cost. An opt-in scheduled auto-shutdown (e.g.microsoft.devtestlab/schedules), timed outside the deployment window and accounting for the ~2 hour max sandbox deployment, is needed so it never cuts off an in-flight run. Critical consequence: once auto-shutdown deallocates the VM, the self-hosted runner agent is offline, so a self-hosted job cannot start its own host — the CD workflow needs a preflight job running off the VM (GitHub-hostedubuntu-latest) that checks VM power state, starts it (az vm start) if deallocated, waits until the VM is running and the runner is online (GitHub runners API), and fails fast on timeout. The main provisioning jobneeds:this preflight. The preflight authenticates via a minimal, narrowly-scoped OIDC identity (custom role: VM read +Microsoft.Compute/virtualMachines/start/action, scoped to just that VM/RG). Runner service must auto-start on boot; local state must persist across shutdown/restart.
(State backend is not a blocker, but it is now required, not optional: CD always uses the remote azurerm backend.)
Todos
Phase tags: [S1] Scenario 1, [S1 → S2] built for Scenario 1 and extended for Scenario 2, [S2] Scenario 2 only, [Later] not needed for the first end-to-end tests.
— ✅ DONE (Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496, PR Add managed identity authentication mode for Terraform providers #743). Selectableprereq-dual-authsecret/msiauth across all three providers;arm_client_secretconditional; manual secret flow preserved and re-validated. OIDC intentionally not implemented.- [S1]
backend-config(required) — Addbackend.tf(azurerm,use_azuread_auth = true,use_msi = true) wired to therg-devops-iactfstate storage account/container via partial-config (-backend-configinputs supplied atterraform init). Not optional. No account names committed; values come from Environment-scoped variables. — ✅ DONE (rg-devops-iac: optionally provision a user-assigned managed identity for MSI-mode sandbox applies #797, PR feat(rg-devops-iac): optional user-assigned managed identity for MSI-mode sandbox applies (#797) #802). Opt-inrunner-uamienable_user_assigned_identityinrg-devops-iaccreates a user-assigned managed identity with subscriptionOwnerand attaches it tojumplinux2alongside the system-assigned identity, which stays the default. It exposesuser_assigned_identity_client_id(use it asarm_client_idfor root sandbox applies witharm_auth_mode = "msi"). Microsoft GraphGroup.ReadWrite.All(needed only withmssql) is granted manually after the apply by a Global Administrator or Privileged Role Administrator, using the idempotentextras/configurations/rg-devops-iac/scripts/grant-graph-permissions.sh. The prerequisites are documented in the configuration README. Validated on an existing deployment: the VM was updated in place, the identity signed in withOwner, its Graph token carriedGroup.ReadWrite.All, and the runner was unaffected. Not yet tested: attaching the identity when the VM is first created (blocked by vm-jumpbox-linux: Az PowerShell module install on jumplinux1 is very slow (~36s/module) and now blocks terraform apply #801), and turning the flag off. Deferred to [Later]: an identity smoke-test section in the README, a pointer to the grant script in Step 4, and an architecture diagram update.— ✅ DONE (Optionally register the rg-devops-iac VM as a GitHub Actions self-hosted runner #499, PR Add opt-in GitHub Actions self-hosted runner to rg-devops-iac #794). Opt-in self-hosted runner in therunner-setuprg-devops-iacconfiguration: cloud-init installs and registers theactions/runneragent as a service, with the toolchain present (terraform, az, pwsh + Az modules), managed identity Azure auth and the registration credential delivered as a write-only key vault secret. No runner-local state directory. Validated end to end (T0-T5): runner online with labels, serviceactiveandenabled, token absent from state, a real dispatched job routed and ran as the unprivileged service account with the managed identity and full toolchain, and command-scoped sudo verified in both directions. Toggling the feature replaces the VM (custom_dataforces replacement), so it must never be toggled by an apply running on the runner. Hardened (rg-devops-iac: configure-github-runner.sh fails when apt lock is held during runner dependency install #795, PR fix(rg-devops-iac): avoid apt lock race during runner provisioning (#795) #796): removed cloud-initpackage_upgrade/package_reboot_if_required(security patching is left tounattended-upgrades) and wrapped the runner'sinstalldependencies.shinretry, so registration no longer fails when another process (e.g. a VM extension) holds the apt lock. Re-validated on a fresh deployment: cloud-initdonewith no errors, single boot, runner online with labels.- [S1]
tfvars-input— Define and document how the manually authoredterraform.tfvarsreaches the runner: either supplied whole (placed on the runner / provided as a protected workflow input) or assembled by the workflow from discrete inputs. Either way it must setarm_auth_mode = "msi",arm_client_id,user_object_id,location,tags, the module toggles, and any environment-specific tags needed to satisfy or opt out of organizational Azure Policy (see Design update comment). No bootstrap-script involvement; no secrets in the file. Because the file is environment-specific it is never committed. — ✅ DONE (Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498, PR Emit machine-readable test results (JSON + JUnit XML) from Invoke-UnitTests.ps1 #790). Add optional JSON/JUnit machine-readable output toprereq-test-outputInvoke-UnitTests.ps1with a per-check breakdown (backward compatible); consumed byworkflow-teststo render per-check failures. Prerequisite for per-check precision.- [Later (Workflow-driven start/stop for the rg-devops-iac runner VM #501)]
workflow-preflight-start— Author the preflight job (GitHub-hostedubuntu-latest, runs first,needs-ed by the provision job): OIDC login with a minimal VM-start-scoped identity → check runner VM power state →az vm startif deallocated → wait until VM running and the self-hosted runner is online (GitHub runners API) → fail fast on timeout. Then on the runner, detect leftover config from a previous run and honorclean_previous_run(defaultfalse). - [S1 → S2]
workflow-provision— Author.github/workflows/cd-provision-sandbox.yml. Triggers:on: pull_request(branches: [main], types opened/synchronize/reopened) +workflow_dispatch(the vnext→main release-PR gate; not run on vnext PRs). Same-repo guard (skip fork PRs). Inputs: location, module toggles, admin UPN, tags, optionaluse_remote_state,rbac_mode(standing_ownerdefault |pim_jit),keep_sandbox(defaultfalse),clean_previous_run(defaultfalse). The provision job runs on the self-hosted runner, uses an Environment (approval gate), andneeds: [workflow-preflight-start]; steps: checkout (clean: false) → MI login → [pim_jitonly] PIM-activate Owner (await approval) → supplyterraform.tfvars(manually authored, whole or assembled from inputs) →terraform init→validate→plan→apply→ [pim_jitonly] PIM-deactivate Owner. Noenable-public-access.shneeded for a fresh Scenario-1 apply. - [S2]
merge-gate-setup— Document repo-admin setup (not code): the CD check as a required status check inmainbranch protection, and the Environment (with required reviewers) used as the approval gate. Cover same-repo-only policy. - [Later (Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500)]
prereq-pim-jit(via Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500) — Implement Option B PIM-for-Groups JIT elevation (Reader → Owner with approval): role-assignable group, MI membership, eligible Owner assignment + approval policy, Graph PIM API activate/deactivate steps, selectable viarbac_mode. Entra ID P2. - [Later (Workflow-driven start/stop for the rg-devops-iac runner VM #501)]
prereq-auto-shutdown(via Workflow-driven start/stop for the rg-devops-iac runner VM #501) — Add an opt-in scheduled auto-shutdown for therg-devops-iacrunner VM (configurable time/zone), timed outside the deployment window and accounting for the ~2 h max deployment so CD runs are never cut off; runner service auto-starts on boot, local state persists across restart. - [S1]
workflow-azure-login— Add an explicit Azure login step at the start of every job that runs on the self-hosted runner, before anyazor Az PowerShell call:az login --identityandConnect-AzAccount -Identity(two independent token caches). Verified necessary by testing (see the runner validation comment): cloud-init performsaz login --identityas root, but jobs run as the unprivileged runner service account, which has its own$HOME/.azureand therefore inherits no token -az account showfails withPlease run 'az login' to setup account.. NoteARM_USE_MSI/ARM_TENANT_IDare present in the runner service environment and cover the Terraform providers only; they do not authenticate the CLI. Required byworkflow-tests(Invoke-UnitTests.ps1) andmanage-vms.sh, both of which shell out to Azure CLI / Az PowerShell. - [S1]
workflow-tests— Post-apply: VM-start gate (manage-vms.sh start+ confirm running) →Connect-AzAccount -Identity→Invoke-UnitTests.ps1. Surface results precisely: fail the job on non-zero exit, write a per-module ✅/❌ results table to$GITHUB_STEP_SUMMARY, emit::error::annotations for failed modules/checks, upload the test log + JSON/JUnit (Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498) as artifacts. - [S1 → S2]
workflow-cleanup— Post-run teardown driven bykeep_sandbox(defaultfalse), running only on success: whenfalse, read the sandbox RG name fromterraform output -json resource_names→az group delete --yes --no-wait→./scripts/cleanterraformtemp.sh; whentrue, skip cleanup. - [S1]
concurrency-guard— Only a single CD run may be active at a time. Add a workflow-levelconcurrencygroup withcancel-in-progress: falseso overlapping runs queue rather than abort a 25-95 minute apply mid-flight. This is a hard design constraint, not just a safety net: there is one runner, one sandbox subscription footprint and one backend state file, and a sandbox deployment is large and expensive. Mirrors the "one apply at a time" hard rule. - [S1 → S2]
security-guard— Set the repo fork-PR approval setting (Settings -> Actions -> General) to the strictest available option; this is the only control a hostile fork PR cannot edit, sincepull_requestruns the workflow YAML from the PR head. UI-only, so document it as a manual admin step. Then add the explicit same-repo/head-ref condition to the provisioning job and confirm the Environment required-reviewer gate applies to every trigger path (includingworkflow_dispatch). See the Design update comment for the control-layering table. - [S1]
docs— README/CONTRIBUTING: new "Continuous Deployment" section (prerequisites,rg-devops-iacdependency, runner setup, inputs, auth model, security notes, terminology caveat above); update copilot-instructions if needed. - [S1]
ci-lint— Ensure the new workflow passesactionlint(ci-actions) and any added HCL passesterraform fmt/tflint/validate(ci-terraform).
Open items / future iterations (out of current scope)
- Scenario 2 (enable/disable modules on an existing sandbox) and Scenario 3 (VM re-provision repro) as additional workflows or inputs — these would add the
enable-public-access.shstep before plan/apply (and, for re-applies against persistent local state, even Scenario 1 re-runs would). - Auto-open a GitHub issue on failure (mirror the copilot-instructions error policy) via a final
if: failure()step. - Scheduled teardown/destroy workflow for cost control.
- OIDC GitHub-hosted-runner variant (enabled by Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496) as an alternative to the self-hosted provisioning runner, should network isolation requirements ever change.
— ❌ NOT PLANNED (Add GitHub App authentication mode for self-hosted runner registration #793 closed). Registering a repository-level runner needs the App to hold the Repository administration permission, and repository admins cannot install Apps that request it, so an organization owner would have to install the App. Runner registration stays on the fine-grained PAT (prereq-runner-github-app(via Add GitHub App authentication mode for self-hosted runner registration #793)github_runner_token_type = "pat").
Notes / considerations
-
Runner registration PAT lifetime: the PAT is used only once, at VM provisioning, to get a short-lived registration token; after that the runner authenticates with its own credentials, so a short-lived PAT (e.g. one with a 24-hour policy limit) expiring does not affect an already-registered runner. A valid PAT must be in the
github-runner-tokenkey vault secret before any apply that replaces the runner VM. It can be written as a new secret version withaz keyvault secret set(afterenable-public-access.sh) instead of incrementinggithub_runner_token_secret_version; Terraform does not track write-only values, so this shows up as no drift. GitHub automatically removes self-hosted runners that have been offline for more than 14 days, so a VM left deallocated that long must be re-registered or rebuilt (relevant to Workflow-driven start/stop for the rg-devops-iac runner VM #501). -
rg-devops-iacRG naming (verified — no change needed): it already usesmodule.naming.resource_group.name_uniquewith the identical naming-module config as the Azure Sandbox root (suffix = [project, environment],unique-include-numbers = true,unique-length = 8); there is no static/defaultresource_group_namevariable. Each config gets its own random-suffixed unique RG name (stable within its own state). -
Invoke-UnitTests.ps1is CI-ready (exit 0/1) and auto-discovers deployed modules;-Integrationis ignored without-Module, so an all-modules run already includes integration tests. -
GitHub job 6-hour limit comfortably covers the 25–95 min apply; long single-resource waits (gateways) are healthy as long as
Still creating...heartbeats advance. -
Never read/inspect state while an apply is in flight; the concurrency guard enforces single apply.
-
backend.tfis currently absent and.gitignoreexcludes*.tfstate*. CD requires the remoteazurermbackend, configured via partial-config inputs rather than committing account names. Runner-local state is not used (superseded — see Design update comment).
Activity
doherty100 commentedon Jun 22, 2026
Issue #173 — CD: GitHub Action to provision an Azure Sandbox
Problem
Issue #173 asks for a GitHub Action that provisions a new sandbox environment (continuous
deployment). The repo today is built for interactive/manual execution only. Several aspects
of the current architecture block a headless CD process and must be resolved first.
Approach (agreed with user)
Build a GitHub Actions workflow that automates the non-interactive
equivalent of the copilot-instructions Scenario 1 (fresh provision) plus the post-apply
VM-start gate +
Invoke-UnitTests.ps1, and gates the run on the unit-test result(exit 0 = pass / 1 = fail). It is the final release gate on the
vnext → mainPR — it runsautomatically on
pull_requesttargetingmain(plusworkflow_dispatchfor manual re-runs) andpublishes a required status check in
mainbranch protection, so the merge intomainfailsunless CD passes. It runs on a self-hosted runner on the
rg-devops-iacLinux VMusing that VM's managed identity (no stored secrets). Terraform state defaults to local
.tfstatepersisted on that runner VM (under the repo root), with an opt-in Azure Storageremote backend (the
rg-devops-iactfstate account) documented for durability.Decisions captured
self-hosted VM (not an ephemeral GitHub-hosted runner), a local
.tfstatesurvives betweenruns and is the default. Two caveats the workflow must handle: (a)
actions/checkoutdefaults to
clean: true(git clean -ffdx) which deletes untrackedterraform.tfstate— useclean: falseand/or keep state at a fixed path outside the workspace (-state=/var/lib/.../ terraform.tfstate); (b) local state has no versioning/backup/leasing, so loss of the runner VMorphans resources. Opt-in remote backend: a
backend.tf(azurerm,use_azuread_auth = true)using partial-config (
-backend-configinputs) can point at therg-devops-iactfstate storageaccount/container for durability; documented but off by default.
rg-devops-iacremains aprerequisite (it provides the runner VM + MI; its tfstate account is only needed for the remote
option).
rg-devops-iacTerraform-execution VM using itsmanaged identity (Owner on the subscription + Graph perms). No long-lived secrets.
providers.tf+variables.tfcurrentlyhardcode/require SPN client-secret auth, blocking MSI/OIDC. Filed and linked sub-issue
Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496 to add a selectable auth mode (
secretdefault |msi|oidc) acrossazurerm,azapi,azuread, makingarm_client_secretconditional while keeping the current manualsecret flow working unchanged. Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496 must land before the CD workflow can use managed identity.
user_object_id/user_namegrant a human RBAC + AAD groupmembership. These are non-secret identifiers. The workflow takes the admin UPN as a
workflow_dispatchinput and resolvesuser_object_idat runtime via Microsoft Graph(
az ad user show --id <upn>) using the runner's managed identity. Mirrors the "signed-in user"concept for "the person who creates the CD" with no secret storage.
bootstrap.sh/bootstrap.ps1are interactive andJWT-decode the signed-in user, so they can't run headless and would resolve the wrong identity
under a CD managed identity. Filed and linked sub-issue Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497 to add a headless mode to
both scripts (all values via flags/env,
user_object_idresolved from the admin UPN viaGraph, module toggles settable, interactive default preserved). The CD workflow then calls the
headless bootstrap as the single source of truth for
terraform.tfvars— no bespokegeneration logic duplicated in the workflow. Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497 must land before the workflow's tfvars step.
access at the end of an apply. On a fresh Scenario-1 apply (empty state) the resources are
created with public access enabled, written to, then disabled — all within the single apply —
so no
enable-public-access.shstep is needed.persistent local state, re-running the workflow against that existing state becomes a
Scenario-2 re-apply where the prior barrier already locked down public access; that path would
require
enable-public-access.shfirst. Out of scope here (Scenario 1 only) — documented as aknown limitation / future item.
(
scripts/manage-vms.sh start+ confirmVM running) thenInvoke-UnitTests.ps1(all installedmodules; unit+integration auto-run), authenticating via
Connect-AzAccount -Identity. "Test-resultgating" means all of the following, not merely a red ❌:
1= test failures,2=harness error), so the run goes red and blocks.
Markdown results table to
$GITHUB_STEP_SUMMARY(✅/❌ per module, Passed/Failed counts,overall), rendered on the run's summary page so failures are visible without opening logs.
::error::workflow commands for each failed module (and, whereavailable, each failed
[FAIL]check) so they appear as inline annotations.actions/upload-artifactso a user can download complete per-check detail.machine-readable output (JSON/JUnit) to
Invoke-UnitTests.ps1(sub-issue Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498), which theworkflow-testsstep consumes to render per-check failures robustly (Job Summary table +::error::annotations + JSON/JUnit artifacts). Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498 is a prerequisite for the per-checkrendering (the workflow's coarser per-module table/annotations work without it).
pull_requestwithbranches: [main](opened/synchronize/reopened — i.e. thevnext → mainrelease PR) plus
workflow_dispatchfor manual re-runs. It is not run on the manyday-to-day PRs targeting
vnext(too slow/costly); the real deploy event is the release PR intomain. The CD job publishes a status check that is configured as a required status check inmainbranch protection, so a merge intomainis blocked/fails unless CD passes (provisionRestricted to same-repo PRs (vnext→main is internal; self-hosted runner / MI must never run
fork code). Branch protection + the Environment are repo-admin settings (documented setup, not
code). New PR commits (
synchronize) re-run the check; the single runner/state serializes runs.keep_sandboxinput, defaultfalse): a workflowinput
keep_sandboxcontrols post-run teardown, acting only on a passing run (apply + testsgreen; a failed run retains everything for debugging and goes red):
keep_sandbox = false(DEFAULT) → auto-clean on success: (1) delete the sandbox resourcegroup via
az group delete -n <sandbox-rg> --yes --no-wait— notterraform destroy(fast,and avoids destroy-ordering issues); the sandbox RG name is read from
terraform output -json resource_names(resource_group) before deletion; (2) clean therunner working tree via
./scripts/cleanterraformtemp.sh(removesterraform.tfvars,terraform.tfstate*,.terraform/, lock files, SSH keys,*.pem/.pfx/.log/.tfplan), so thenow-stale local state (referencing deleted resources) is gone and the runner is ready for the
next run.
keep_sandbox = true→ no cleanup; sandbox RG and runner config/state preserved (sandboxstays Terraform-managed for later Scenario-2 work or manual inspection).
cleanterraformtemp.shis repo-root scoped, so for it tofully clean the local state the CD must keep
terraform.tfstateinside the workspace/reporoot on the persistent runner (with
checkout clean: false) rather than a path outside therepo. This supersedes the earlier "fixed out-of-workspace state path" idea — the two cleanup
mechanisms (the script + this layout) must agree.
clean_previous_runinput: because state is persistent onthe runner, a prior run (especially
keep_sandbox = true, or an aborted/failed run) can leaveterraform.tfvars/terraform.tfstate/.terraform/on the runner. The preflight jobdetects leftover config and honors a
clean_previous_runinput (defaultfalse): whentrueit runs
./scripts/cleanterraformtemp.shfirst to start fresh; whenfalseand leftover is foundit fails fast with guidance (so a run can't silently operate on stale state from a different
sandbox).
keep_sandboxcleanup. (Scenario 2/3 reuse and failure-auto-issue are out of scope this iteration — see Open
items.)
Blockers (surfaced; do not start CD workflow until cleared)
workflow-testsstep can render per-check failures robustly (Job Summary table +
::error::annotations +JSON/JUnit artifacts). The coarser per-module surfacing degrades gracefully without it, but
per-check precision (the agreed requirement) is blocked on Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498.
rg-devops-iac— register the runner declaratively as anopt-in part of the
rg-devops-iacVM provisioning (cloud-init installs/registers theactions/runneragent as a service, toolchain present, uses the VM managed identity,token via Key Vault). Replaces the former manual "runner registration" step. Default off.
rbac_modeinput): the applywrites role assignments and creates/populates AAD groups, so during the apply window the
runner identity needs Owner (or User Access Administrator + Contributor) at subscription
scope — Reader alone cannot apply. The workflow selects between two modes:
permanent subscription Owner role. Lowest complexity; consistent with the manual SPN today;
long-lived privileged CI identity. Fine for a throwaway sandbox subscription.
→ sub-issue Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500): MI holds only standing Reader; Owner is activated just-in-time per
run (PIM for Groups: MI is a member of a role-assignable group eligible for Owner; a workflow
step requests activation via the Graph PIM API, an approver approves, then it deactivates).
Requires Entra ID P2. Smallest blast radius; adds approval-gate + activate/deactivate steps.
rbac_modeworkflow input so the CD pipeline canexercise both paths; standing Owner is the default.
User.Read.All, group create/membershipfor the
mssqlsql_admins group) are app-role grants on the MI and remain standing,scoped least-privilege — not PIM-JIT'd like Azure RBAC.
rg-devops-iaccurrently has no power-state lifecycle management for the runner VM, so itwould run indefinitely and leak cost. Add an opt-in scheduled auto-shutdown (e.g.
microsoft.devtestlab/schedules), timed outside the deployment window and accounting for the~2 hour max sandbox deployment so it never cuts off an in-flight run. Critical
consequence: once auto-shutdown deallocates the VM, the self-hosted runner agent is offline,
so a self-hosted job cannot start its own host. The CD workflow therefore needs a preflight job
that runs OFF the VM (a GitHub-hosted
ubuntu-latestjob) as its first step: itchecks the runner VM power state, starts it (
az vm start) if deallocated, waits until the VMis running AND the runner is online (GitHub runners API), and FAILS FAST if it doesn't come up
within a timeout. The main self-hosted provisioning job
needs:this preflight. The preflightauths via a minimal, narrowly-scoped OIDC identity (custom role: VM read +
Microsoft.Compute/virtualMachines/start/action, scoped to just that VM/RG — far smaller blastradius than the provisioning identity, no stored secret). Runner service must auto-start on boot
and local state must persist across shutdown/restart.
(State backend is not a blocker: local state is the default; the remote azurerm backend is an
optional opt-in for durability.)
Todos
prereq-dual-auth— Land sub-issue Support secretless managed-identity Terraform provider auth in addition to SPN client secret #496 (selectable secret/msi/oidc auth across all threeproviders;
arm_client_secretconditional; manual secret flow preserved). Blocks everything.backend-config— (opt-in) Add an optionalbackend.tf(azurerm,use_azuread_auth,partial-config via
-backend-configinputs) wired to therg-devops-iactfstate storageaccount/container for durability; off by default (local state is the default). Document
switching between local and remote state. No account names committed.
runner-setup— (via sub-issue Optionally register the rg-devops-iac VM as a GitHub Actions self-hosted runner #499) Add an opt-in self-hosted runner to therg-devops-iacconfiguration: cloud-init installs/registers the
actions/runneragent as a service on the VM,with the toolchain present (terraform, az, pwsh + Az modules), MI-based Azure auth, registration
token via Key Vault, and local-state persistence in the workspace/repo root (
checkout clean: false, state kept under repo root socleanterraformtemp.shcan clean it). Declarativereplacement for manual runner registration. Verify MI RBAC (Owner + Graph).
tfvars-generation— (via sub-issue Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497) Land headless mode inbootstrap.sh/bootstrap.ps1(all inputs via flags/env,
user_object_idresolved from admin UPN via Graph, module togglessettable, interactive default preserved); the workflow calls headless bootstrap as the single
source of truth — no bespoke tfvars logic in the workflow.
prereq-test-output— (via sub-issue Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498) Add optional JSON/JUnit machine-readable output toInvoke-UnitTests.ps1with a per-check breakdown (backward compatible); consumed byworkflow-teststo render per-check failures. Prerequisite for per-check precision.
workflow-preflight-start— Author the preflight job (GitHub-hostedubuntu-latest, runsfirst,
needs-ed by the provision job): OIDC login with a minimal VM-start-scoped identity →check runner VM power state →
az vm startif deallocated → wait until VM running and theself-hosted runner is online (GitHub runners API) → fail fast on timeout. Then on the
runner, detect leftover config from a previous run and honor
clean_previous_run(defaultfalse): iftrue, run./scripts/cleanterraformtemp.shto start fresh; iffalseand leftoverexists, fail fast. Solves the deallocated-runner chicken-and-egg and stale-state hazards.
workflow-provision— Author.github/workflows/cd-provision-sandbox.yml. Triggers:on: pull_request(branches: [main], types opened/synchronize/reopened) +workflow_dispatch(the vnext→main release-PR gate; not run on vnext PRs). Same-repo guard (skip fork PRs).
workflow_dispatch/PR inputs (location, module toggles, admin UPN, tags, optionaluse_remote_state,rbac_mode=standing_owner(default) |pim_jit,keep_sandbox(defaultfalse),clean_previous_run(defaultfalse)) — PR-triggered runs use repo/Environment defaults.The provision job runs on the self-hosted runner, uses an Environment (approval gate), and
needs: [workflow-preflight-start]; steps:checkout (
clean: false) → MI login → [pim_jitonly] PIM-activate Owner (request via GraphPIM API, await approval) → resolve oid → headless bootstrap (Add headless/non-interactive mode to bootstrap.sh and bootstrap.ps1 for CD #497) to write tfvars
→
terraform init(local by default, remote if opted in) →validate→plan→apply(local state under repo root) → [
pim_jitonly] PIM-deactivate Owner. Noenable-public-access.sh— not needed for a fresh Scenario-1 apply (the barrier runs internallywithin the single apply).
merge-gate-setup— Document repo-admin setup (not code): the CD check as a required statuscheck in
mainbranch protection (so vnext→main merges fail unless CD passes), and theEnvironment (with required reviewers) used as the approval gate. Cover same-repo-only policy.
prereq-pim-jit— (via sub-issue Secretless, approval-gated GitHub OIDC access to an unattached deployment UAMI #500) Implement Option B PIM-for-Groups JIT elevation(Reader → Owner with approval): role-assignable group, MI membership, eligible Owner assignment +
approval policy, Graph PIM API activate/deactivate steps, selectable via
rbac_mode. Entra ID P2.prereq-auto-shutdown— (via sub-issue Workflow-driven start/stop for the rg-devops-iac runner VM #501) Add an opt-in scheduled auto-shutdown for therg-devops-iacrunner VM (configurable time/zone), timed outside the deployment window andaccounting for the ~2 h max deployment so CD runs are never cut off; runner service auto-starts on
boot, local state persists across restart.
workflow-tests— Post-apply: VM-start gate (manage-vms.sh start+ confirm running) →Connect-AzAccount -Identity→Invoke-UnitTests.ps1. Surface results precisely: fail the jobon non-zero exit, write a per-module ✅/❌ results table to
$GITHUB_STEP_SUMMARY, emit::error::annotations for failed modules/checks, and upload the test log + JSON/JUnit(Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498) as artifacts. Per-check precision comes from the machine-readable output added in Emit machine-readable test results (JSON/JUnit) from Invoke-UnitTests.ps1 for CI/CD #498.
workflow-cleanup— Post-run teardown driven bykeep_sandbox(defaultfalse), running onlyon success (a failed run retains everything for debugging): when
false, read the sandbox RGname from
terraform output -json resource_names→az group delete --yes --no-wait(notterraform destroy) →./scripts/cleanterraformtemp.shto wipe the now-stale local state +temp files from the runner; when
true, skip cleanup (sandbox + state preserved).concurrency-guard— Add workflowconcurrencygroup to prevent concurrent applies against thesame state (mirrors "one apply at a time" hard rule).
docs— README/CONTRIBUTING: new "Continuous Deployment" section (prerequisites, rg-devops-iacdependency, runner setup, inputs, auth model, security notes); update copilot-instructions if
needed.
ci-lint— Ensure new workflow passesactionlint(ci-actions) and any added HCL passesterraform fmt/tflint/validate(ci-terraform).Open items / future iterations (out of current scope)
as additional workflows or inputs — these would add the
enable-public-access.shstep beforeplan/apply (and, for re-applies against persistent local state, even Scenario 1 re-runs would).
if: failure()step.Notes / considerations
rg-devops-iacRG naming (verified — no change needed): it already usesmodule.naming.resource_group.name_uniquewith the identical naming-module config as theAzure Sandbox root (
suffix = [project, environment],unique-include-numbers = true,unique-length = 8); there is no static/defaultresource_group_namevariable. Each configtherefore already gets its own random-suffixed unique RG name (stable within its own state), so
it follows the same pattern as Azure Sandbox. No sub-issue filed for this.
Invoke-UnitTests.ps1is CI-ready (exit 0/1) and auto-discovers deployed modules;-Integrationis ignored without
-Module, so an all-modules run already includes integration tests.(gateways) are healthy as long as
Still creating...heartbeats advance.backend.tfis currently absent and.gitignoreexcludes*.tfstate*. Default CD path keepsstate local on the persistent runner (at a fixed out-of-workspace path so
checkoutcan'twipe it); the opt-in remote backend uses partial-config inputs rather than committing account
names. Local state has no backup/versioning — accept this tradeoff or opt into remote.
doherty100 commentedon Sep 17, 2026
Addendum — hardening the self-hosted-runner trigger (public-repo security)
Follow-up from a design review of the self-hosted-runner approach: being the sole approver of the
vnext → mainmerge is necessary but not sufficient to protect the self-hosted runner, since (a) a naivepull_requesttrigger onbranches: [main]would schedule a job on the runner for any PR opened againstmain— including a fork PR opened directly againstmain— independent of whether that PR would ever be merged, and (b) the code that actually executes during CD is whatever is onvnextat merge time, so the real control point is PR review discipline on everyvnext-bound change (workflows, scripts, Terraform), not just the final merge click.Adding two explicit requirements to this issue's scope (not a new sub-issue — these belong to the CD workflow definition itself):
workflow_dispatch(i.e.workflow_dispatchstill targets the same protected Environment). This is the standard mitigation for self-hosted runners on public repositories: the job pauses before touching the runner until a human approves, giving a last checkpoint even if a run is unexpectedly triggered.These should be added as explicit acceptance criteria for the CD workflow itself (in addition to the existing "Restricted to same-repo PRs" note already captured above), and verified before the self-hosted runner is enabled for real.
doherty100 commentedon Sep 17, 2026
Terminology note: qualifying "CD" for this project
Following a design discussion, clarifying what "CD" means in this issue (and its sub-issues) so future readers don't assume a persistent, always-on
azuresandboxenvironment is being kept up to date by this pipeline — it isn't.Definition used here: this pipeline is Continuous Delivery in the classic sense (every change is automatically validated as release-ready), but implemented as an ephemeral plan/apply validation gate:
vnext, runs the full unit/integration test suite against that live deployment, and reports pass/fail as a required status check.vnext → main) — this pipeline never auto-deploys to a persistent target.keep_sandbox = falsedefault) — there is no long-lived environment this pipeline maintains or keeps in sync.Shorthand for this repo:
CD (ephemeral plan/apply validation gate)— i.e. CD is being used to prove "this code deploys and passes tests," not to keep an environment continuously current. Sub-issues #496–#501 reference "#173 (CD)" — that shorthand should be read with this definition in mind.doherty100 commentedon Sep 21, 2026
Decision: OIDC is not a Terraform provider mode for the current CD gate
The required #173 validation gate must run the complete unit and integration suite. Although a GitHub-hosted runner can authenticate to Azure with OIDC and dispatch most VM-side tests through ARM Run Command, it cannot complete the required vWAN/P2S integration test after the sandbox deployment is finalized. That test runs on the Terraform execution host and retrieves the P2S client private key from the sandbox Key Vault. The public-access barrier disables Key Vault public access at the end of apply, so a GitHub-hosted runner cannot retrieve that secret.
Therefore, the CD architecture remains: a GitHub-hosted job may use a narrowly scoped OIDC identity only to start the runner VM;
jumplinux2then performs Terraform and the test suite using its managed identity and private state backend. #496 will support the existing secret default and managed identity, but excludes OIDC Terraform-provider authentication. A future direct GitHub-hosted Terraform design would require a secure alternative for the P2S client credential and a deliberately designed state/test-network topology.doherty100 commentedon Sep 22, 2026
Authentication boundary refinement
For #496 and the #173 CD workflow, Terraform supports only the system-assigned managed identity on the
rg-devops-iacself-hosted runner (jumplinux2). User-assigned managed identities are intentionally out of scope.arm_client_idremains exclusively for the existingsecretauthentication mode and must not be set in MSI mode. The workflow's GitHub-hosted OIDC identity remains limited to starting the runner VM; Terraform and tests run on the runner's system-assigned identity.doherty100 commentedon Sep 22, 2026
Superseding MSI identity decision
The earlier system-assigned-runner identity guidance is superseded. #496 now requires a pre-provisioned UAMI attached to the Terraform execution environment;
arm_client_idselects it in MSI mode. This stable principal receives the required Azure RBAC and Microsoft Graph group-management permissions before apply.For #173, attach the UAMI to
jumplinux2. Sandboxjumpwin1remains separate: its system-assigned identity is used only for Azure SQL database-user bootstrap and receives no Terraform UAMI control-plane or Graph permissions.1 remaining item
doherty100 commentedon Sep 22, 2026
Dependency removed: headless bootstrap no longer required
The CD design no longer generates
terraform.tfvarsfrom the bootstrap scripts. It will be authored manually and supplied to the workflow, either as a complete file or as discrete inputs assembled by the workflow, depending on how the remaining sub-issues are implemented.bootstrap.shandbootstrap.ps1stay interactive helpers for the manual service-principal-secret flow and are not part of the automated MSI path. #497 is closed as not planned, and blocker 2 has been struck from the list above. The issue body and todos were updated accordingly.The underlying data requirements are unchanged, they just live in the supplied file:
arm_auth_mode = "msi",arm_client_id, the human adminuser_object_id(the CD identity is not the admin, so it cannot be derived from the signed-in token),location,tags, and the module toggles.doherty100 commentedon Oct 5, 2026
Blocker 3 (#498, machine-readable test output) is complete and is no longer blocking this work.
Landed in #790, merged to
vnexton 2026-10-05.What shipped
Invoke-UnitTests.ps1has new optional-ResultsJsonPathand-JUnitXmlPathparameters. Default behavior is unchanged when they are omitted.result,exitCode, timing), totals, and per-suite / per-check results, including failure detail. Pass/fail totals mirror the[SUMMARY]lines. This is the intended input for theworkflow-testsstep's$GITHUB_STEP_SUMMARYtable and::error::annotations.<testsuite>per module or integration suite, a<failure>per failed check,<skipped>for undeployed modules, and an<error>suite on harness errors. It can be consumed by standard test-reporter actions and uploaded as an artifact.0all passed,1test failures,2harness/prerequisite error.#requires -Modules Az.*causedpwsh -Fileto return0even on failure. It is replaced by an explicitImport-Module, so the workflow can now reliably gate on the process exit code.Validation: run against a freshly provisioned sandbox with all base modules. A passing run returned exit
0(109/109 checks). A run with transient environmental failures returned exit1(105 passed, 4 failed). In both cases the JSON and JUnit counts matched the console output.Remaining blockers: #499 (self-hosted runner), the RBAC option (#500), and #501 (runner-VM auto-shutdown and workflow-driven start).
doherty100 commentedon Oct 6, 2026
Design update — CD decisions ratified
Design review while implementing #499. These decisions are now reflected in the issue body; this comment records the rationale and flags what changed.
1. Terraform state: remote backend always (supersedes the previous local-state default)
The issue previously specified runner-local
.tfstateas the default with an opt-in remote backend. That is reversed. CD always uses theazurermbackend in therg-devops-iactfstate storage account (use_azuread_auth = true,use_msi = true), configured by partial-config-backend-configinputs atterraform init.Rationale — runner-local state on a CI host is an anti-pattern:
Knock-on effects, all simplifications:
actions/checkoutkeeps its defaultclean: true; theclean: falserequirement is gone.cleanterraformtemp.shcatches it" reconciliation is gone.github_runner_state_directoryinput was removed before it shipped).Clarifying the bootstrap scenario. The one apply that legitimately uses local state is the initial apply of
rg-devops-iacitself, which creates the storage account that later holds state — a genuine chicken-and-egg. That apply runs from a separate execution environment before the runner exists, so the runner can never be the thing performing it. This is a one-time bootstrap, not a CD mode.2. CD is from-scratch only
CD validates that a release can build a sandbox from nothing. It is explicitly not a mechanism for incremental or in-place updates to a long-lived environment.
if: always()so a failed apply still cleans up).enable-public-access.shstep is needed: on a fresh apply the barrier creates the resources with public access enabled, writes to them, and disables it within the same apply.3. Only one CD run active at a time
Promoted from a safety net to a hard design constraint. Workflow-level
concurrencygroup withcancel-in-progress: falseso overlapping runs queue instead of aborting an apply that may be 25–95 minutes in.Justification: there is exactly one self-hosted runner, one sandbox footprint and one backend state file, and a sandbox deployment is large and expensive. Cancelling mid-apply is the worst outcome — it leaves partially provisioned resources and dirty state.
4. Self-hosted runner is mandatory, not a convenience
Worth stating explicitly because it drives the entire security posture. A GitHub-hosted runner cannot do this job:
So the hardening below is not optional risk-reduction; it is the price of the only viable architecture.
5. Environment-specific ARM tags (new — not previously captured)
Some subscriptions enforce organizational tagging policies, and some apply
modify-effect policies that disable public network access on Key Vault and Storage. That directly breaks the public-access barrier pattern, which requires public access to remain enabled for the duration of the apply so Terraform can perform data-plane writes. An environment-specific ARM tag is required to opt out.Mechanism in CD: the supplied
terraform.tfvarsfile. CD already supplies a manually authored, environment-specificterraform.tfvarsto the runner (thetfvars-inputtodo). Any tags needed to satisfy or opt out of organizational policy simply live in that file. There is no need to plumb anadditional_tagsvalue through the workflow or onto the command line.The
-var='additional_tags={...}'command-line pattern is for interactive and agent-driven installs, wherebootstrap.shgeneratesterraform.tfvarsand the extra tags have to be layered on per-invocation. It is already implemented and working for that case, and is not a CD concern.The same applies to the
rg-devops-iacdeployment, which is a one-time bootstrap performed from a separate execution environment with its own tfvars.Requirements that remain:
tfvars-inputdesign must explicitly account for these tags, so a CD run in a policy-constrained tenant is not broken by a missing tag.6. Security hardening is a hard requirement
A self-hosted runner attached to a public repo is the dominant risk in this design: a malicious PR that can trigger a workflow job gets arbitrary code execution on a persistent VM holding a managed identity with elevated Azure rights.
Threat model — the controls are not equally strong. For a
pull_requesttrigger, GitHub runs the workflow YAML from the PR head, not from the base branch. A fork PR can therefore editcd-provision-sandbox.ymlitself — deleting theenvironment:key or the same-repoif:guard — or simply edit a.sh/.ps1/.tffile the workflow already executes. So any control expressed inside the workflow file is PR-editable and cannot be the outermost defense:environment:if:guardThe fork-PR approval setting is the control that actually holds, because it is evaluated before any PR-supplied YAML is read. The in-workflow controls are defense-in-depth against misconfiguration and same-repo mistakes, not against a hostile fork.
Required controls:
workflow_dispatch.github.event.pull_request.head.repo.full_name == github.repository).pull_request_targetand no fork triggers.These extend the existing
security-guardandmerge-gate-setuptodos.In practice the intended trigger is the maintainer's own
vnext -> mainrelease PR, so the fork path is an abuse case rather than a normal one — but it is reachable by anyone on a public repo, which is why it is treated as a hard requirement.7. Flakiness must not erode trust in the gate
A gate that fails for environmental reasons gets ignored, then bypassed. Mitigations:
./scripts/manage-vms.sh startVM-start gate before any test run (a policy can deallocate VMs at any time; a deallocated domain controller breaks DNS, Kerberos, domain join, CIFS and SQL auth — the root cause confirmed in vm-jumpbox-linux unit tests fail: AD DNS 'mysandbox.local' does not resolve + CIFS skipped (no Kerberos TGT) [centralus, vnext] #447).8. Resolved —
ARM_USE_MSIin the runner environment is not a conflictRaised as an open question, now closed. #499 writes
ARM_USE_MSI=trueandARM_TENANT_IDinto the runner service environment, and the concern was whether that could interfere with a sandbox apply using SPN auth (arm_auth_mode = "secret").It cannot. Since #496, all three providers set the argument explicitly from the tfvars input:
ARM_USE_MSIis only consulted as an environment default for an argument the provider block does not set. Becauseuse_msiis always set explicitly, the environment variable is never reached andarm_auth_modeis authoritative.Corroborated empirically: cloud-init on the
rg-devops-iacVM already pre-setsARM_USE_MSI=truein the interactive shell, and sandbox deployments have been run successfully from that host. The runner service environment simply replicates a configuration already in use.No action required.
doherty100 commentedon Oct 6, 2026
Runner validation findings (from testing the #499 branch end to end)
The self-hosted runner from #499 was deployed to a
rg-devops-iacenvironment and exercised with a real dispatched workflow job against a private test repository. Summary of what this proves for the CD design, and the one gap it uncovered.Verified working on the runner
runs-on: [self-hosted, <label>])activeandenabled, so it survives the reboot implied by the auto-shutdown work in #501ARM_USE_MSI,ARM_TENANT_ID)PATHGap found: Azure CLI / Az PowerShell are not authenticated inside jobs
A job step running
az account showfails:Cause. Cloud-init performs
az login --identityas root during provisioning. The Azure CLI token cache is per-user ($HOME/.azure), and jobs run as the unprivileged runner service account, which has a different home directory and therefore no cached token.Connect-AzAccounthas its own separate cache with the same problem.Important distinction.
ARM_USE_MSIandARM_TENANT_IDare present in the job environment, so Terraform provider authentication is unaffected -terraform plan/applywork. The gap is limited to the Azure CLI and Az PowerShell, which is precisely whatInvoke-UnitTests.ps1andmanage-vms.shdepend on.Resolution. Fixed in the workflow, not on the VM: a baked-in token cache would be stale, is the wrong lifecycle, and would store credentials on disk unnecessarily. Each job that touches Azure should begin with an explicit login:
The
az login --identityhalf is confirmed working from a job step. Tracked as the newworkflow-azure-logintodo.Other observations relevant to this issue
workflow-preflight-starttimeout budget: if the preflight job ever provisions or re-provisions the runner rather than merely starting it, allow for this.custom_data, which forces replacement. The runner must therefore never be enabled or disabled by an apply executed on the runner itself.doherty100 commentedon Oct 6, 2026
Progress update:
runner-setupcomplete#499 is merged (PR #794) and closed. The
runner-setuptodo is marked done.Prerequisite status
prereq-dual-authprereq-test-outputrunner-setupprereq-pim-jitprereq-auto-shutdownAll three prerequisites that the provisioning workflow depends on directly are now in place: selectable managed identity authentication, machine-readable test output, and a working self-hosted runner.
What the runner now provides
Confirmed by a real dispatched job, not just by inspection:
activeandenabled, so it survives the reboot implied byprereq-auto-shutdown(Workflow-driven start/stop for the rg-devops-iac runner VM #501);PATH;Carried forward
workflow-azure-login(new) — jobs must runaz login --identityandConnect-AzAccount -Identityexplicitly. See the preceding findings comment for the cause.backend-configis now unblocked: the runner exists and its managed identity already holdsStorage Blob Data Contributoron the state storage account.docsshould cover it rather than Optionally register the rg-devops-iac VM as a GitHub Actions self-hosted runner #499.Constraint worth restating before workflow authoring
Enabling or disabling the runner changes
custom_data, which forces replacement of the virtual machine. No workflow running on the runner may ever apply a change toenable_github_runner. Related: replacing or destroying the virtual machine leaves a stale offline runner registered that must be removed out of band.doherty100 commentedon Oct 14, 2026
Progress is updated at the top of this issue, with the older design retained as history.
main.vnextcurrent with the policy sync, complete walkthrough, and resource-group summary enhancement.Leaving #173 open. Bounded runner-local cleanup, #500, #501, and the documented acceptance/reporting gaps remain follow-up work.
doherty100 commentedon Oct 15, 2026
Live acceptance: fresh deployment after an environmental release-gate failure (2026-10-15)
Tested successfully: recover the release gate by deploying the unchanged candidate into a completely new sandbox, without reusing the failed deployment or bypassing the gate.
Sequence and evidence
vnext→main) initially triggered run 38764864917. Apply failed and the release gate did not pass. The operator identified the failure as an environmental P2S VPN gateway provisioning failure, rather than a code defect, and manually deleted the failed sandbox.113cb5bc79083eb35be8308e2b80a78e0e140e0e.sandbox-release-gateenvironment. Preflight, Apply, Unit-test, and final release-status publication all succeeded. The run completed at 20:12 UTC, andsandbox-deployment-gatereported success on the unchanged candidate.e2a63ef4dc1697050f2ca78743f081a8d6fa55af; release v5.14.3 was published afterward. No release-gate bypass was needed.Confirmed operating path
For an environmental failure requiring a completely fresh release-gated deployment: manually clean up the failed sandbox as appropriate, close/reopen the ready release PR, and approve the new deployment run. This preserves candidate identity while allocating independent deployment state and publishing a new gate result.
GitHub Re-run jobs is not the supported fresh-deployment path: it retains the original run ID/state key, and preflight rejects an existing state blob. Manual workflow dispatch can create a fresh sandbox but does not publish the PR release-gate status. Automatic teardown, automatic retries, existing-state recovery, and old-state cleanup were not tested or introduced by this acceptance case.
This supplements the earlier successful release-path acceptance with a live environmental-failure → fresh-deployment → successful-gate-recovery case. Other remaining work under this issue is unchanged.
doherty100 commentedon Oct 17, 2026
Progress: manual deployment source choice (#853)
Manual runs can now deploy released
maincode as well asvnext. The Current operating decisions section above is updated.Delivered
workflow_dispatchhas a newsource_branchchoice:vnext(the default, so earlier behavior is unchanged) ormain. Every stage (preflight,apply,unit-test) works with either source, and runs keep thesandbox-deploymentenvironment and its approvals.refs/heads/<branch>from this repository.... / <stage> / <branch>) and the selection summary show the source. Release PR runs ignore the input and still deploy the exact head SHA throughsandbox-release-gate. Manual runs never publish or satisfyrelease-static-ciorsandbox-deployment-gate.scripts/tests/Test-SandboxDeploymentSelection.shruns the selection step against sample events. It passed 32/32 in the hostedactionlintjob and failed 18 cases against the previous workflow. It also runs from./scripts/Invoke-CIChecks.sh actions.Publication
vnext(squash): the workflow, test script, CI wiring, and docs (setup guide Step 95).main(squash): an authorized maintenance PR containing onlysandbox-deployment.yml, identical to thevnextversion.ci-actionsandci-secretswere dispatched manually and passed. An owner bypass merged it because its source isn'tvnext.vnext(regular merge): records the history of Register manual deployment source choice on main (#853) #869 with no content changes.The test script and documentation reach
mainwith the next normal release.Live acceptance
Manual run 39050200826 (
/ apply / main) selectedmainand skipped every release-only job. After approval, Preflight passed on themaincheckout, and Apply was still in progress when this comment was posted. The operator accepted the feature.#173 remains open for #500, #501, and bounded runner-local cleanup.
doherty100 commentedon Oct 21, 2026
Closeout
All of the CD pipeline's planned sub-issues have shipped to
mainand passed live acceptance.Current state of the Sandbox deployment workflow
vnexttomain(deployment gate), plus manual dispatch with arun_throughstage and asource_branchofvnextormain(Azure Sandbox deployment: allow manual deployment from vnext or main #853).start_runnerjob waits for the deployment approval, then starts the self-hosted runner VM with a separate power state identity.stop_runnerdeallocates the VM only if this run started it. A rejected run never starts the VM, and a VM that was already running is left running (Workflow-driven start/stop for the rg-devops-iac runner VM #501: Runner power state identity, runner Terraform version check, and local PSScriptAnalyzer fix (#501, #906, #902) #907, Start and deallocate the runner VM from the deployment workflow (#501) #908, Maintenance: start and deallocate the runner VM from the deployment workflow (#501) #909).required_versionand installs a compatible version only when needed (Sandbox deployment preflight: ensure runner Terraform CLI is compatible with the deployed branch's required_version #906).Acceptance
main: rejected (run), where the VM was never started, and VM already running (run), where the VM was not stopped.Details are in #500 and #501.