-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathsideload-updates.yml
More file actions
453 lines (424 loc) · 25.6 KB
/
Copy pathsideload-updates.yml
File metadata and controls
453 lines (424 loc) · 25.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# AZLOCAL-PIPELINE-ID: sideload-updates
# Sideload Updates (Opt-in, on-prem self-hosted runner)
#
# OPT-IN, OFF BY DEFAULT. This workflow automates the on-prem sideloading of
# Azure Local solution-update media for clusters that cannot pull updates from
# Azure directly. It is the NEW Step.6 introduced in v0.8.7; the former Step.6
# (Apply Updates) is now Step.7.
#
# WHY A SELF-HOSTED RUNNER:
# The runner must sit on the SAME network as the target Azure Local clusters
# so it can (a) Robocopy the CombinedSolutionBundle to each cluster's
# infrastructure `import` SMB share and (b) PowerShell-remote (WinRM) into a
# cluster node to verify the SHA256 and run Add-SolutionUpdate. GitHub-hosted
# runners cannot reach the on-prem fabric, so this pipeline targets a
# self-hosted runner labelled `azlocal-sideload`.
#
# RE-ENTRANT STATE MACHINE:
# Each run advances every in-scope cluster by ONE state transition and exits.
# The multi-hour copy itself runs in a detached Windows Scheduled Task, so no
# pipeline run is ever long-lived. Drive this on a frequent CRON (e.g. every
# 30 minutes) so the state machine progresses Copying -> Copied -> Verified ->
# Imported across successive short runs. Re-running is always safe.
#
# GATE: the whole job is skipped unless the repository variable
# SIDELOAD_UPDATES == 'true'. When unset/false this file is inert.
#
# AUTHENTICATION:
# - Azure (ARG fleet read + Key Vault secret read + UpdateSideloaded tag flip):
# azure/login OIDC by default. enable-AzPSSession=true is REQUIRED because
# the Key Vault secrets are read via Get-AzKeyVaultSecret (Az PowerShell).
# For on-prem runners where OIDC is not viable, switch to managed identity
# or a service principal per the SIDELOAD_KV_AUTH variable (see comments).
# - Cluster WinRM remoting: an Active Directory [pscredential] built from two
# Key Vault secrets named in the sideload auth-map row (NOT the pipeline
# identity). The detached copy Scheduled Task runs as the runner service
# account (needs UNC rights to the shared cache + cluster import share).
# Workflow name carries the same Step.N - prefix as the filename so the GitHub
# Actions sidebar (which sorts workflows alphabetically by this `name:` field)
# lists the pipelines in execution order.
name: Step.06 - Sideload Updates (Opt-in)
on:
# BEGIN-AZLOCAL-CUSTOMIZE:schedule-triggers
# Edits inside this block are preserved across Update-AzLocalPipelineExample
# module upgrades.
#
# NO default schedule ships out-of-the-box (this pipeline requires an on-prem
# self-hosted runner that most repos do not have). Once your `azlocal-sideload`
# runner is online, UNCOMMENT the cron below to drive the re-entrant state
# machine. A frequent poll (every 30 min) lets a single short run advance the
# multi-hour copy without staying alive. The Step.3 schedule-coverage audit can
# recommend a lead-time-aware cron (apply window minus SIDELOAD_LEAD_DAYS).
#
# schedule:
# - cron: '*/30 * * * *' # every 30 minutes - advance/report sideload state
# END-AZLOCAL-CUSTOMIZE:schedule-triggers
workflow_dispatch:
inputs:
update_ring:
# accepts a single ring (Wave1), a semicolon-delimited list (Prod;Ring2),
# or '***' (three stars - deliberate, not a typo) to match every cluster
# that HAS the UpdateRing tag set.
description: "UpdateRing tag value to scope the plan ('Wave1', 'Prod;Ring2', or '***' for ALL rings)."
required: false
default: '***'
dry_run:
description: 'Preview the plan + transitions without staging media, registering tasks, or flipping tags (WhatIf).'
required: false
default: 'true'
type: choice
options:
- 'true'
- 'false'
module_version:
description: 'Pin AzLocal.UpdateManagement version (empty = latest from PSGallery). See Automation-Pipeline-Examples/README.md section 5 "Optional configuration".'
required: false
default: ''
env:
# Module version this workflow YAML was generated against. The install step compares
# this to the version actually installed and to the latest on PSGallery, and emits a
# ::notice annotation if the YAML appears stale - prompting you to refresh via
# Copy-AzLocalPipelineExample -Update. See Automation-Pipeline-Examples/README.md section 5.
GENERATED_AGAINST_MODULE_VERSION: '0.8.77'
# Resolution order for the module version pin (leave all unset to install the latest,
# which is the default "fix-forward" behaviour): manual workflow_dispatch input >
# repository variable 'REQUIRED_MODULE_VERSION' > empty (latest).
REQUIRED_MODULE_VERSION: ${{ github.event.inputs.module_version || vars.REQUIRED_MODULE_VERSION || '' }}
# v0.8.4 - opt this workflow into Node.js 24 for all JavaScript actions
# (actions/checkout, actions/download-artifact, actions/upload-artifact,
# azure/login, dorny/test-reporter, etc). Per GitHub's 2025-09-19 deprecation
# notice Node 20 is forced off by default on 2026-06-16 and removed from the
# runner on 2026-09-16. Setting this env var silences the deprecation warnings
# and exercises Node 24 ahead of the cut-over. To temporarily opt back out,
# set ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true.
# https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Path to the ring-aware apply-updates-schedule.yml (relative to repo root). Defaults
# to `config/` so it sits alongside the auth-map + catalog (this is where
# Copy-AzLocalPipelineExample drops the starter). The planner reads it to find each
# cluster's next apply window. Override the variable to relocate it.
APPLY_UPDATES_SCHEDULE_PATH: ${{ vars.APPLY_UPDATES_SCHEDULE_PATH || './config/apply-updates-schedule.yml' }}
# ---- Sideload configuration (repository Variables, vars.*) ----
# Master gate. The job is skipped unless this is the literal string 'true'.
SIDELOAD_UPDATES: ${{ vars.SIDELOAD_UPDATES || 'false' }}
# Shared UNC root that ALL runners/agents can read+write: holds state\, logs\, cache\.
SIDELOAD_STATE_ROOT: ${{ vars.SIDELOAD_STATE_ROOT || '' }}
# Shared verified media cache. Defaults (in the cmdlet) to <state-root>\cache.
SIDELOAD_CACHE_ROOT: ${{ vars.SIDELOAD_CACHE_ROOT || '' }}
# Path to the sideload auth-map CSV (UpdateAuthAccountId -> Key Vault + secret names).
# Defaults to `config/` so it sits alongside apply-updates-schedule.yml and
# ClusterUpdateRings.csv (this is where Copy-AzLocalPipelineExample drops the
# starter). Override the variable to relocate it.
SIDELOAD_AUTH_MAP_PATH: ${{ vars.SIDELOAD_AUTH_MAP_PATH || './config/sideload-auth-map.csv' }}
# Path to the committed sideload catalog YAML (Version -> DownloadUri/Sha256).
# Defaults to `config/` alongside the auth-map + schedule. Override to relocate.
SIDELOAD_CATALOG_PATH: ${{ vars.SIDELOAD_CATALOG_PATH || './config/sideload-catalog.yml' }}
# Days before a cluster's next apply window that media should be sideloaded.
SIDELOAD_LEAD_DAYS: ${{ vars.SIDELOAD_LEAD_DAYS || '7' }}
# Extra robocopy switches for the detached copy worker (throttling / retries).
SIDELOAD_ROBOCOPY_SWITCHES: ${{ vars.SIDELOAD_ROBOCOPY_SWITCHES || '/R:5 /W:30' }}
# Minutes after which a Copying heartbeat is considered stale (dead runner) and re-driven.
SIDELOAD_HEARTBEAT_STALE_MINUTES: ${{ vars.SIDELOAD_HEARTBEAT_STALE_MINUTES || '60' }}
# Global FQDN suffix appended to a cluster name to form the WinRM host when the
# auth-map row does not override it (e.g. '.corp.contoso.com'). Empty = bare name.
SIDELOAD_REMOTING_FQDN_SUFFIX: ${{ vars.SIDELOAD_REMOTING_FQDN_SUFFIX || '' }}
# Key Vault auth mode for the on-prem runner: oidc | managedidentity | serviceprincipal.
SIDELOAD_KV_AUTH: ${{ vars.SIDELOAD_KV_AUTH || 'oidc' }}
# Prevent overlapping sideload runs from racing on the same shared state.
# cancel-in-progress=false queues a newly-triggered run behind an in-flight one.
concurrency:
group: sideload-updates-${{ github.workflow }}
cancel-in-progress: false
jobs:
# ---------------------------------------------------------------------------
# PREFLIGHT (always runs on a Microsoft-hosted Windows runner)
# ---------------------------------------------------------------------------
# Purpose: explain - in the run step summary - WHY the `sideload` job did or
# did not run, so an operator who triggers Step.6 without first completing
# the opt-in setup sees a clear "what to do next" panel instead of a silent
# "Skipped" status (the symptom that prompted v0.8.76).
#
# Cost: ~10s on windows-latest. No Azure/Key Vault access. Always runs.
#
# Outcomes:
# - Gate OFF (SIDELOAD_UPDATES unset / not in 'true'|'True'|'TRUE'|'1'):
# preflight succeeds (exit 0) with an informational panel showing how
# to enable Step.6. The `sideload` job is then SKIPPED by its own `if:`.
# - Gate ON + SIDELOAD_STATE_ROOT missing: preflight FAILS (exit 1) with
# a panel listing the missing variable(s). The `sideload` job is
# skipped because its `needs: preflight` is unmet.
# - Gate ON + best-effort runner check shows no online `azlocal-sideload`
# runner: preflight FAILS (exit 1). NOTE: the runner enumeration API
# requires repo-admin permission which GITHUB_TOKEN does not normally
# grant; if the API call fails with 403/404 the preflight degrades
# gracefully and emits a strong warning instead of a hard failure.
# - All checks pass: preflight succeeds, `sideload` job proceeds.
preflight:
name: Step.6 Preflight - Validate Opt-in + Self-Hosted Runner
runs-on: windows-latest
permissions:
actions: read # Best-effort runner enumeration (may 403 - graceful fallback)
contents: read
steps:
- name: Validate opt-in, configuration, and self-hosted runner
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$summaryPath = $env:GITHUB_STEP_SUMMARY
function Append-Summary([string]$line) {
Add-Content -LiteralPath $summaryPath -Value $line -Encoding utf8
}
# Master gate: case-insensitive 'true'/'True'/'TRUE' OR literal '1'.
$gateRaw = "$($env:SIDELOAD_UPDATES)".Trim()
$gateOn = ($gateRaw -in @('true','True','TRUE','1'))
Append-Summary '## Step.6 - Sideload Updates - Preflight'
Append-Summary ''
if (-not $gateOn) {
Append-Summary '> [!NOTE]'
Append-Summary '> **Step.6 (Sideload Updates) is OPT-IN and currently OFF.**'
Append-Summary '>'
if ([string]::IsNullOrWhiteSpace($gateRaw)) {
Append-Summary '> The repository variable `SIDELOAD_UPDATES` is **not set**. The `sideload` job for this run was skipped.'
} else {
Append-Summary "> The repository variable ``SIDELOAD_UPDATES`` is set to ``'$gateRaw'`` which is not one of the accepted opt-in values (``'true'`` / ``'True'`` / ``'TRUE'`` / ``'1'``). The ``sideload`` job for this run was skipped."
}
Append-Summary ''
Append-Summary '### How to enable Step.6'
Append-Summary ''
Append-Summary '1. **Provision an on-prem self-hosted runner** on the same network as your Azure Local clusters. Register it with **both** labels `self-hosted` (default) and `azlocal-sideload` (custom). Use a dedicated runner group if you want to restrict which repos can target it. See [Adding self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners).'
Append-Summary '2. **Stage the config files** in your repository under `./config/`:'
Append-Summary ' - `apply-updates-schedule.yml` (the ring-aware apply schedule - generated by Step.3 Schedule Audit)'
Append-Summary ' - `sideload-auth-map.csv` (per-cluster Key Vault + secret-name map for WinRM credentials)'
Append-Summary ' - `sideload-catalog.yml` (committed catalog of approved `CombinedSolutionBundle` versions, `Version` -> `DownloadUri` + `Sha256`)'
Append-Summary '3. **Set the repository Variables** (Settings -> Secrets and variables -> Actions -> Variables):'
Append-Summary ' - `SIDELOAD_STATE_ROOT` = shared UNC path the runner can read/write (e.g. `\\fileserver\azlocal-sideload`)'
Append-Summary ' - `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` (Azure login)'
Append-Summary ' - Optional: `SIDELOAD_CACHE_ROOT`, `SIDELOAD_LEAD_DAYS`, `SIDELOAD_ROBOCOPY_SWITCHES`, `SIDELOAD_HEARTBEAT_STALE_MINUTES`, `SIDELOAD_REMOTING_FQDN_SUFFIX`, `SIDELOAD_KV_AUTH`'
Append-Summary '4. **Set the repository Secret** `AZURE_CLIENT_ID` (the OIDC app registration used by `azure/login`).'
Append-Summary '5. **Flip the master gate**: set repository variable `SIDELOAD_UPDATES` = `true` (also accepted: `True`, `TRUE`, `1`).'
Append-Summary ''
Append-Summary '> [!IMPORTANT]'
Append-Summary '> Step.6 cannot run on a Microsoft-hosted runner. The job copies multi-GB update media to the cluster `import` SMB share and PowerShell-remotes into a cluster node - both require line-of-sight to the on-prem fabric VLAN and (typically) AD domain membership.'
Append-Summary ''
Append-Summary 'See `Automation-Pipeline-Examples/README.md` section 8.6 (Sideload Updates) for the full setup walkthrough.'
Write-Output "::notice title=Step.6 sideload is OPT-IN::SIDELOAD_UPDATES is not set to an opt-in value ('true'/'True'/'TRUE'/'1'). See the run summary for enablement instructions."
exit 0
}
# Gate is ON - validate required configuration.
Append-Summary '### Master gate'
Append-Summary ''
Append-Summary "- ``SIDELOAD_UPDATES`` = ``'$gateRaw'`` (opt-in)"
Append-Summary ''
Append-Summary '### Required configuration'
Append-Summary ''
$missing = [System.Collections.Generic.List[string]]::new()
$stateRoot = "$($env:SIDELOAD_STATE_ROOT)".Trim()
if ([string]::IsNullOrWhiteSpace($stateRoot)) {
[void]$missing.Add('SIDELOAD_STATE_ROOT')
Append-Summary '- `SIDELOAD_STATE_ROOT` - **MISSING** (required: shared UNC root reachable from the self-hosted runner, e.g. `\\fileserver\azlocal-sideload`)'
} else {
Append-Summary "- ``SIDELOAD_STATE_ROOT`` = ``$stateRoot``"
}
Append-Summary "- ``SIDELOAD_AUTH_MAP_PATH`` = ``$($env:SIDELOAD_AUTH_MAP_PATH)``"
Append-Summary "- ``SIDELOAD_CATALOG_PATH`` = ``$($env:SIDELOAD_CATALOG_PATH)``"
Append-Summary "- ``APPLY_UPDATES_SCHEDULE_PATH`` = ``$($env:APPLY_UPDATES_SCHEDULE_PATH)``"
Append-Summary ''
if ($missing.Count -gt 0) {
Append-Summary '> [!CAUTION]'
Append-Summary "> **Setup incomplete.** Missing required variable(s): ``$($missing -join '`, `')``. The ``sideload`` job will not run."
Append-Summary ''
Append-Summary 'See `Automation-Pipeline-Examples/README.md` section 8.6 (Sideload Updates) for the full setup walkthrough.'
Write-Output "::error title=Step.6 preflight failed::Required variable(s) not set: $($missing -join ', ')"
exit 1
}
# Best-effort: query the runners API to confirm an online self-hosted runner
# with the 'azlocal-sideload' label is registered. The repo runners API
# requires admin permission; GITHUB_TOKEN typically does NOT have it, so
# 403/404 is expected for most callers and downgrades the check to a warning.
Append-Summary '### Self-hosted runner'
Append-Summary ''
$runnerCheckPassed = $false
$runnerCheckSkipped = $false
try {
$headers = @{
Authorization = "Bearer $env:GH_TOKEN"
Accept = 'application/vnd.github+json'
'X-GitHub-Api-Version' = '2022-11-28'
'User-Agent' = 'AzLocal.UpdateManagement-preflight'
}
$uri = "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runners?per_page=100"
$response = Invoke-RestMethod -Uri $uri -Headers $headers -ErrorAction Stop
$matching = @($response.runners | Where-Object {
$labelNames = @($_.labels | ForEach-Object { $_.name })
($labelNames -contains 'self-hosted') -and ($labelNames -contains 'azlocal-sideload')
})
$online = @($matching | Where-Object { $_.status -eq 'online' })
if ($online.Count -gt 0) {
Append-Summary "- **Found $($online.Count) online runner(s)** with the ``azlocal-sideload`` label:"
foreach ($r in $online) {
Append-Summary " - ``$($r.name)`` (os: $($r.os), status: $($r.status), busy: $($r.busy))"
}
$runnerCheckPassed = $true
} elseif ($matching.Count -gt 0) {
Append-Summary "- **Found $($matching.Count) runner(s)** with the ``azlocal-sideload`` label, but **none are online**:"
foreach ($r in $matching) {
Append-Summary " - ``$($r.name)`` - status: $($r.status)"
}
} else {
Append-Summary '- **No self-hosted runner registered with the `azlocal-sideload` label.**'
}
}
catch {
$runnerCheckSkipped = $true
$msg = $_.Exception.Message
Append-Summary "- Unable to enumerate runners via the GitHub API ($msg)."
Append-Summary ' - The repo runners API requires admin permission; `GITHUB_TOKEN` usually cannot read it. This is expected and not necessarily a problem.'
Append-Summary ' - **Manual verification:** Settings -> Actions -> Runners. Confirm a self-hosted runner exists with the `azlocal-sideload` label and is **Idle** (green).'
}
Append-Summary ''
if (-not $runnerCheckPassed -and -not $runnerCheckSkipped) {
Append-Summary '> [!CAUTION]'
Append-Summary '> **No online `azlocal-sideload` runner.** The `sideload` job would queue indefinitely (default GH Actions job queue timeout: 24h) until a matching runner comes online. Failing preflight now to prevent a stuck run.'
Append-Summary ''
Append-Summary 'Register a self-hosted runner on a Windows machine on the cluster network, label it with `azlocal-sideload` (the `self-hosted` label is added automatically), then re-run this workflow. See `Automation-Pipeline-Examples/README.md` section 8.6.'
Write-Output "::error title=Step.6 preflight failed::No online self-hosted runner with the 'azlocal-sideload' label was found in this repository."
exit 1
}
if ($runnerCheckSkipped) {
Append-Summary '> [!WARNING]'
Append-Summary '> Preflight could not verify runner availability. If no `azlocal-sideload` runner is registered the `sideload` job will queue silently for up to 24h.'
Append-Summary ''
} else {
Append-Summary '> [!TIP]'
Append-Summary '> **Preflight passed.** The `Advance Sideload State Machine` job will run on your self-hosted runner.'
Append-Summary ''
}
sideload:
name: Advance Sideload State Machine
# Wait for preflight to pass before queuing onto the self-hosted runner.
# If preflight FAILS (missing config / no runner), this job is skipped.
# If preflight succeeds with the gate OFF, this job is skipped by its own `if:`.
needs: preflight
# Requires an on-prem self-hosted RUNNER (GitHub Actions terminology) on the
# same network as the clusters, registered with the `azlocal-sideload` label
# (use a dedicated runner group to restrict which repos can target it).
runs-on: [self-hosted, azlocal-sideload]
# Master opt-in gate: do nothing unless SIDELOAD_UPDATES is one of the
# accepted truthy values (case-insensitive 'true' or literal '1').
if: ${{ contains(fromJSON('["true","True","TRUE","1"]'), vars.SIDELOAD_UPDATES) }}
permissions:
id-token: write
contents: read
checks: write # Required for the test reporter
steps:
- name: Checkout repository
uses: actions/checkout@v5
# Azure auth. enable-AzPSSession=true sets up BOTH the az CLI context (ARG +
# az rest tag writes) AND the Az PowerShell context (Get-AzKeyVaultSecret).
#
# SIDELOAD_KV_AUTH alternatives for on-prem runners where OIDC is not viable:
# - managedidentity: replace this step with
# run: Connect-AzAccount -Identity
# - serviceprincipal: store AZURE_CLIENT_SECRET as a repo secret and use
# azure/login with creds, or Connect-AzAccount -ServicePrincipal -Credential ...
- name: Azure Login (OIDC + Az PowerShell session)
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Install Azure CLI Resource Graph Extension
shell: pwsh
run: |
az extension add --name resource-graph --yes
- name: Install AzLocal.UpdateManagement from PSGallery
shell: pwsh
id: module-version
run: |
$ErrorActionPreference = 'Stop'
$installArgs = @{ Name = 'AzLocal.UpdateManagement'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true }
if ($env:REQUIRED_MODULE_VERSION) {
$installArgs.RequiredVersion = $env:REQUIRED_MODULE_VERSION
Write-Host "REQUIRED_MODULE_VERSION is set - pinning install to v$($env:REQUIRED_MODULE_VERSION)."
} else {
Write-Host "REQUIRED_MODULE_VERSION is empty - installing the latest version from PSGallery (default fix-forward behaviour)."
}
Install-Module @installArgs
Import-Module AzLocal.UpdateManagement -Force
Add-AzLocalPipelineVersionBanner `
-GeneratedAgainstVersion $env:GENERATED_AGAINST_MODULE_VERSION `
-PinnedVersion $env:REQUIRED_MODULE_VERSION
- name: Advance Sideload State Machine
id: sideload
shell: pwsh
env:
INPUT_UPDATE_RING: ${{ github.event.inputs.update_ring || '***' }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }}
run: |
$ErrorActionPreference = 'Stop'
Import-Module AzLocal.UpdateManagement -Force
if ([string]::IsNullOrWhiteSpace($env:SIDELOAD_STATE_ROOT)) {
throw "SIDELOAD_STATE_ROOT is not set. Configure the shared UNC state root repository variable before running this pipeline."
}
# 1. Resolve the per-cluster plan (read-only).
$planParams = @{
SchedulePath = $env:APPLY_UPDATES_SCHEDULE_PATH
AuthMapPath = $env:SIDELOAD_AUTH_MAP_PATH
CatalogPath = $env:SIDELOAD_CATALOG_PATH
LeadDays = [int]$env:SIDELOAD_LEAD_DAYS
}
if ($env:INPUT_UPDATE_RING) { $planParams['UpdateRingValue'] = $env:INPUT_UPDATE_RING }
if ($env:SIDELOAD_REMOTING_FQDN_SUFFIX) { $planParams['FqdnSuffix'] = $env:SIDELOAD_REMOTING_FQDN_SUFFIX }
$plan = @(Resolve-AzLocalSideloadPlan @planParams)
Write-Host ("Resolved {0} cluster plan row(s)." -f $plan.Count)
# 2. Advance the re-entrant state machine by one transition per cluster.
$applyParams = @{
Plan = $plan
StateRoot = $env:SIDELOAD_STATE_ROOT
ErrorAction = 'Stop'
}
if ($env:SIDELOAD_CACHE_ROOT) { $applyParams['CacheRoot'] = $env:SIDELOAD_CACHE_ROOT }
if ($env:SIDELOAD_ROBOCOPY_SWITCHES) { $applyParams['RobocopySwitches'] = $env:SIDELOAD_ROBOCOPY_SWITCHES }
if ($env:SIDELOAD_HEARTBEAT_STALE_MINUTES) { $applyParams['HeartbeatStaleMinutes'] = [int]$env:SIDELOAD_HEARTBEAT_STALE_MINUTES }
if ($env:INPUT_DRY_RUN -eq 'true') {
$applyParams['WhatIf'] = $true
Write-Host 'DRY RUN MODE - no media staged, no tasks registered, no tags flipped.'
}
$results = @(Invoke-AzLocalSideloadUpdate @applyParams)
Write-Host ("Processed {0} cluster(s)." -f $results.Count)
# 3. Render the status report (markdown step summary + JUnit XML + sideload-status.md).
New-Item -ItemType Directory -Path ./reports -Force | Out-Null
$report = Add-AzLocalSideloadStepSummary -StateRoot $env:SIDELOAD_STATE_ROOT -Plan $plan -OutputPath ./reports
if ($report.HasFailures) {
Write-Host '::warning::One or more clusters are in a Failed state or have plan errors - see the Sideload status report.'
}
- name: Compute Artifact Timestamp
if: always()
id: artifact-stamp
shell: pwsh
# every downloadable artifact gets a UTC timestamp suffix so multiple runs on
# the same day produce distinct zip names.
run: |
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss')
"timestamp=$stamp" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Host "Artifact timestamp: $stamp"
- name: Upload Sideload Reports
if: always()
uses: actions/upload-artifact@v6
with:
name: azlocal-step.6-sideload-updates-report_${{ steps.artifact-stamp.outputs.timestamp }}
path: ./reports/
retention-days: 90
- name: Publish Test Results
uses: dorny/test-reporter@v3
if: always()
with:
name: Sideload Update Report
path: ./reports/sideload-junit.xml
reporter: java-junit
fail-on-error: false
list-suites: failed
list-tests: failed