Skip to content

AzLocal.UpdateManagement v0.8.5: thin-YAML port across all 10 Step pipelines (35 → 55 cmdlets, zero UI delta)#74

Merged
NeilBird merged 22 commits into
mainfrom
users/nebird/development
Jun 10, 2026
Merged

AzLocal.UpdateManagement v0.8.5: thin-YAML port across all 10 Step pipelines (35 → 55 cmdlets, zero UI delta)#74
NeilBird merged 22 commits into
mainfrom
users/nebird/development

Conversation

@NeilBird

Copy link
Copy Markdown
Owner

Summary

This PR completes the v0.8.5 thin-YAML port of the AzLocal.UpdateManagement automation pipelines. The multi-hundred-line inline run: | PowerShell blocks that previously lived inside each Step's GitHub Actions YAML and Azure DevOps YAML are now condensed into Public cmdlets that both pipelines invoke. Zero UI / outcome delta — step output names, artifact contents, JUnit XML, markdown summaries, error messages, exit codes, and stageDependencies bindings are preserved byte-for-byte. Customers running v0.8.4 YAML against the v0.8.5 module continue to work (the install-time drift banner surfaces version skew but does not block).

Scope: 19 commits ahead of main; 45 files changed (12,419 insertions / 8,486 deletions). The total exported function count grows from 35 (v0.8.4) → 55 (v0.8.5).

What changed (Step.0 → Step.9)

14 new Public cmdlets, one per Step plus shared helpers. Step.6 (apply-updates — the largest pipeline) splits across 6 cmdlets because of its readiness-gate + ITSM + per-host short-circuit paths.

Step New Public cmdlet(s) GH YAML ADO YAML
Shared foundation Add-AzLocalPipelineVersionBanner, Set-AzLocalPipelineOutput, Add-AzLocalPipelineStepSummary, Write-AzLocalPipelineWarning, New-AzLocalPipelineJUnitXml
0 Export-AzLocalAuthValidationReport thin thin
1 Invoke-AzLocalClusterInventory thin thin
2 Set-AzLocalClusterUpdateRingTagFromCsv thin thin
3 Export-AzLocalApplyUpdatesScheduleAudit thin thin
4 Export-AzLocalFleetConnectivityStatusReport thin thin
5 Export-AzLocalClusterUpdateReadinessReport thin thin
6 Resolve-AzLocalPipelineUpdateRing · Export-AzLocalClusterReadinessGateReport · Invoke-AzLocalReadinessGatedClusterUpdate · Add-AzLocalApplyUpdatesStepSummary · Add-AzLocalNoReadyClustersStepSummary · Invoke-AzLocalItsmTicketingFromArtifact 964 → 480 (-484) 985 → 440 (-545)
7 Export-AzLocalUpdateRunMonitorReport thin thin
8 Export-AzLocalFleetUpdateStatusReport thin thin
9 Export-AzLocalFleetHealthStatusReport thin thin

Cumulative reduction across all 20 templates: approximately 9,000 lines removed from Automation-Pipeline-Examples/.

Per-host parity preserved inside the cmdlets

Each new cmdlet auto-detects the host ($env:TF_BUILD for ADO, $env:GITHUB_ACTIONS for GH) and:

  • Step-output naming: GH uses UPPER_SNAKE (e.g. READY_COUNT); ADO uses PascalCase (e.g. ReadyCount) so existing stageDependencies.<job>.outputs['readiness.ReadyCount'] consumers keep working.
  • Icon style: GH renders Unicode emoji; ADO uses GitHub-Markdown shortcodes (:white_check_mark: etc.) which render correctly on the Azure DevOps pipeline summary tab.
  • Warning emission: GH emits ::warning:: workflow commands; ADO emits ##vso[task.logissue type=warning].

The YAML files only carry wiring (triggers, env, OIDC, artifact upload, concurrency, action-version pins) — all of which is preserved exactly.

Benefits (now)

  • Single source of truth. Logic previously duplicated between GitHub Actions YAML and Azure DevOps YAML now lives in one cmdlet. Behaviour drift between platforms — historically the Add ARB Alert Rules toggle to ARB Status tab (v0.5.8) #1 maintenance pain — is eliminated.
  • Diffable + reviewable YAML. Reviewers see "one step calls one cmdlet" instead of 300 lines of inline PowerShell. YAML reviews can now focus on wiring (triggers, env, OIDC, action versions).
  • Operator discoverability. Each cmdlet has Get-Help documentation. Operators can run any Step's logic interactively (Resolve-AzLocalPipelineUpdateRing -Now ...) instead of copy-pasting from a run: block.
  • Re-usable building blocks. Add-AzLocalPipelineVersionBanner, Set-AzLocalPipelineOutput, Add-AzLocalPipelineStepSummary, Write-AzLocalPipelineWarning, New-AzLocalPipelineJUnitXml are shared across every Step cmdlet. The next pipeline doesn't re-invent host-detection, step-output emission, JUnit framing, or markdown rendering.

Benefits (going forward — future maintenance)

  • A bug fix or feature lands in one Public cmdlet rather than two near-identical inline YAML blocks. Forgetting to mirror the change into the second YAML is no longer possible.
  • New Steps follow a tested template (Public/<Verb>-AzLocal<Noun>.ps1 + a thin step: YAML invocation) instead of a copy-paste-from-the-last-Step exercise.
  • Adding new CI/CD platforms (e.g. GitLab CI, Jenkins) is now mostly a wiring exercise — the business logic is already in the module.
  • Version drift between consumer YAML and installed module is automatically surfaced by Add-AzLocalPipelineVersionBanner at every install step.

Pester testing simplification

Pre-port, inline run: | blocks could only be guarded by string-match Pester assertions against the YAML text (yaml | Should -Match 'foo'). These guards:

  • caught typos / structural drift, but
  • could not actually exercise the logic — no mocks, no branching coverage, no error-path proof, no per-host parity proof,
  • failed silently when the underlying behaviour drifted in a way that still text-matched the pattern,
  • couldn't catch parser errors in the inline script body (those only surfaced in production).

Post-port, the new Public cmdlets are unit-tested in Tests/AzLocal.UpdateManagement.Tests.ps1 with mockable seams:

  • Get-AzLocalClusterUpdateReadiness, Start-AzLocalClusterUpdate, Resolve-AzLocalCurrentUpdateRing, etc. can be mocked.
  • Short-circuit paths (no-ready-clusters, missing schedule file, both inputs empty, missing CSV) are asserted via Pester It blocks.
  • Per-host name-mapping (GH UPPER_SNAKE vs ADO PascalCase) is asserted by toggling $env:TF_BUILD / $env:GITHUB_ACTIONS.
  • Host-aware icons are asserted end-to-end (no more "did we remember to swap the emoji for the shortcode on ADO?").

Final Pester baseline on this branch: 1069 passed, 0 failed, 1 skipped, ~4m38s. Function-count drift test bumped 35 → 55 in two sites.

Notable bug fix shaken out during this work

Invoke-AzLocalReadinessGatedClusterUpdate's seven-counter emitter was originally defined as $emitCounters = { ... }.GetNewClosure(). .GetNewClosure() rebinds the scriptblock to the caller's SessionState — which makes Private (non-exported) module functions invisible from inside the closure. The empty-ready short-circuit path then threw Set-AzLocalPipelineOutput is not recognized because the private helper was unreachable. Fix: drop .GetNewClosure() and rely on lexical scoping (the inline scriptblock already has access to the surrounding $n* variables when invoked via & $emitCounters from inside the same function). Pester regression guards now cover both GH and ADO short-circuit paths.

Documentation

  • AzLocal.UpdateManagement/CHANGELOG.md — full breakdown under ## [0.8.5] → "Thin-YAML refactor across all 10 Step pipelines (Step.0 - Step.9)".
  • AzLocal.UpdateManagement/README.md — "What's New in v0.8.5" updated with the 35 → 55 export delta and a new item 8 summarising the thin-YAML port.
  • AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md — no changes needed; it documents file names + appendix links, which are unchanged.

Backwards compatibility

  • No public API removed.
  • No parameter changes on existing cmdlets.
  • Every Step pipeline keeps its filename, name: field, trigger block (cron / workflow_dispatch / schedules / parameters), env block, concurrency block, OIDC login + Option 2 commented block, action-version pins, step IDs, artifact names, ITSM markers, and per-host step-output naming.
  • Consumer pipelines that were running v0.8.4 YAML against the v0.8.4 module continue to work against the v0.8.5 module (the install-time drift banner surfaces the version skew but does not block).

Validation

  • Local Pester run: 1069 passed, 0 failed, 1 skipped, ~4m38s on Windows PowerShell 5.1, fresh module import.
  • Manual inspection: every new cmdlet appears in (Import-Module ... -PassThru).ExportedFunctions (55 total).
  • Module manifest function-export list matches the on-disk Public/*.ps1 glob.
  • All *.ps1 files are ASCII-compatible (no em-dashes / smart quotes / non-ASCII).

NeilBird added 22 commits June 9, 2026 22:23
…ip line

Two Pester drift-test issues found by full-suite execution:

1. ADO Step.6 banner test expected 2 emits per file (one per install step).
   The second install step (ApplyUpdates stage) deliberately skips
   ##vso[task.uploadsummary] - there is a comment in the yml explaining
   that this avoids duplicating the banner in the rendered Summary. Correct
   the expectation to 1 per yml (10 ADO Summary emits total).

2. RBAC bare-name test flagged the migration-tip blockquote in
   Automation-Pipeline-Examples/README.md that intentionally quotes
   "Name": "Azure Stack HCI Update Operator" to explain the rename.
   Allowlist lines that contain "pre-v0.8.4" - that marker is exclusive
   to explicit migration documentation.

Also correct the stale "11 ADO install sites" claim in:
- AzLocal.UpdateManagement/README.md (What's New in v0.8.4)
- AzLocal.UpdateManagement/CHANGELOG.md (v0.8.4 section, 2 places)
- AzLocal.UpdateManagement/AzLocal.UpdateManagement.psd1 (ReleaseNotes)

Accurate description: one banner per yml across all 20 bundled Step.{0..9}.yml
templates (Step.6 ADO has 2 install steps but the second deliberately skips
upload to avoid duplicating the banner).

Drift subset re-run: 8 Passed, 0 Failed.
Foundation cmdlet for the v0.8.5 thin-YAML refactor (roadmap commit 2/13).
Condenses the ~50-line install/drift/banner block from every Step.*.yml
into a single cmdlet call: resolves installed module version, looks up
latest on PSGallery via Find-Module (skippable), emits up to three drift
annotations via Phase 0 pipeline-host helpers, appends a one-line markdown
banner to the rendered step summary, and emits three step outputs
(installed_module_version / generated_against_version / latest_on_psgallery).

Host detection (GitHub Actions / Azure DevOps / Local) flows through the
existing Get-AzLocalPipelineHost / Set-AzLocalPipelineOutput /
Add-AzLocalPipelineStepSummary / Write-AzLocalPipelineNotice /
Write-AzLocalPipelineWarning helpers - no per-host branching inside the
cmdlet itself.

Exported via BOTH psd1 FunctionsToExport AND psm1 Export-ModuleMember
(psm1 has an explicit allow-list, not auto-discover - psd1-only changes
are insufficient).

Tests: 15 new It blocks covering ValidatePattern, in-sync verdict, pinned
vs latest pin-status, three host-mode step outputs, GH step-summary banner
regex, YAML-newer WARNING annotation, YAML-older NOTICE annotation,
Find-Module failure swallowed, newer-on-PSGallery NOTICE + verdict.
Full suite: 934 passed / 0 failed / 1 skipped.
…ve_for_date_utc)

GH workflow_dispatch.inputs gains 'use_schedule_file' (choice false/true,
default false) + 'resolve_for_date_utc' (string, YYYY-MM-DD). ADO
parameters gains symmetric 'useScheduleFile' (boolean) + 'resolveForDateUtc'
(string). Lets an operator trigger Step.6 manually but resolve UpdateRing
+ AllowedUpdateVersions from apply-updates-schedule.yml exactly as a
scheduled run would.

Use cases: (a) test a schedule change before the next scheduled tick,
(b) re-run a missed scheduled day, (c) preview a future cycleWeek /
dayOfWeek via resolve_for_date_utc.

Resolver decision tree (both platforms):
  1. Manual + use_schedule_file=false -> manual ring verbatim (v0.8.4 back-compat)
  2. Manual + use_schedule_file=true  -> read schedule, resolve for today UTC (or supplied date)
  3. Manual + use_schedule_file=false + empty update_ring -> throw with remediation
  4. Scheduled trigger -> unchanged (resolve schedule for UTC now)

update_ring / updateRing is no longer required:true (paired with the
use_schedule_file=true escape hatch). resolve_for_date_utc is parsed via
[datetime]::ParseExact 'yyyy-MM-dd' with AssumeUniversal | AdjustToUniversal
so the date is treated as UTC regardless of agent timezone.

Pester guard already shipped: 12 It blocks under
Context 'v0.8.5 Step.6 manual schedule-file input' cover both yml
declarations + resolver script env-var wiring + the both-empty throw +
the yyyy-MM-dd parsing path. Suite 934/0/1.
Full v0.8.5 release notes covering:
  1. New Public cmdlet Get-AzLocalApplyUpdatesScheduleCycleCalendar
     (calendar projection of apply-updates-schedule.yml across one full
     cycle - default CycleWeeks * 7 days - structured object pipeline
     or rendered markdown via -AsMarkdown).
  2. New Public cmdlet Add-AzLocalPipelineVersionBanner (thin-YAML
     foundation - condenses ~50 lines of install/drift/banner per yml).
  3. New Step.6 manual schedule-file inputs (use_schedule_file +
     resolve_for_date_utc on both GH and ADO).
  4. Regression fix: v0.8.4 cycle calendar silently dropped on healthy
     fleets (architectural decoupling from advisor findings gate).
  5. New per-day Clusters-in-ring(s) column + per-ring Cluster-count
     column (-ClusterRingCounts hashtable on the new cmdlet).
  6. New optional ### Per-ring projection section
     (-IncludePerRingSummary on the new cmdlet).
  7. Test-AzLocalApplyUpdatesScheduleCoverage -View Recommend now
     delegates Enhancement B to the new cmdlet.
  8. GENERATED_AGAINST_MODULE_VERSION 0.8.4 -> 0.8.5 across all 20
     bundled Step.{0..9}.yml templates.

No public API removed. No parameter changes on existing cmdlets.
…ared JUnit emitter

- New Public cmdlet Export-AzLocalAuthValidationReport replaces the ~200-line
  inline run: | block in Step.0_authentication-test.yml (GH + ADO). Runs the
  four auth/RBAC/ARG probes (az account show, az role assignment list,
  az account list, Resource Graph cluster reachability), writes
  auth-report.xml (JUnit), subscriptions.json, subscriptions.csv, emits the
  markdown summary, and produces three pipeline step outputs
  (subscription_count, cluster_count, auth_valid).
- New Private helper New-AzLocalPipelineJUnitXml provides a single shared
  JUnit XML emitter so every Step.* cmdlet produces consistent, well-formed
  testsuites/testcase output. Includes XML-escaping of attribute values and
  UTF-8 no-BOM output.
- Step.0 install step now calls Add-AzLocalPipelineVersionBanner (Public)
  in place of the ~50-line inline install/drift/banner block. ADO variant
  uses -PassThru to forward the version triple to job-scoped variables for
  the report step to consume.
- 41 functions exported (was 40).
- Tests:
  * +9 It blocks for New-AzLocalPipelineJUnitXml shape/escaping
  * +10 It blocks for Export-AzLocalAuthValidationReport behaviour
  * Banner-emit guard updated to accept Add-AzLocalPipelineVersionBanner
    calls (alongside the legacy inline literal) and ignore the cmdlet name
    when it appears only in a YAML comment line
  * Drift-guard test (installed-older-than-generated, v0.7.66) now treats
    Add-AzLocalPipelineVersionBanner as an equivalent emitter (the cmdlet
    runs the installed -lt generated comparison internally and raises the
    warning via Write-AzLocalPipelineWarning)
- Pester: 952 passed / 0 failed / 1 skipped (3 min 16 s).
- New Public cmdlet collapses ~250-line inline run: | blocks in
  Step.1_inventory-clusters.yml (GH + ADO) into a single call.
- Calls Get-AzLocalClusterInventory once (was twice in v0.8.4 yml),
  writes timestamped + canonical CSV, JSON, README, and step summary.
- Emits 4 pipeline outputs via Set-AzLocalPipelineOutput
  (cluster_count, with_tag_count, without_tag_count, csv_path).
- Registered in psd1.NestedModules + psd1.FunctionsToExport +
  psm1.Export-ModuleMember; module now exports 42 functions (was 41).
- 8 new unit tests cover PassThru shape, artifact writes, empty fleet,
  GH + ADO host outputs, SubscriptionFilter -> -SubscriptionId pass-through,
  UpdateRing distribution sorting, single-cluster shape.
- Pester: 924 passed / 0 failed / 1 skipped.
Collapses ~250 lines of inline run/script blocks across the GitHub Actions
and Azure DevOps Step.2_manage-updatering-tags.yml pipelines into a single
Public cmdlet call. The cmdlet:

- Reuses Set-AzLocalClusterUpdateRingTag for the actual per-cluster tag
  write (no duplication of Azure CLI tag logic).
- Validates the CSV up front (existence, required ResourceId/UpdateRing
  columns, non-empty UpdateRing column) and emits the full TWO-STAGE
  operator recovery guidance via Write-Host on a missing file.
- Writes UpdateRingTag_Results.json sidecar into -OutputDirectory.
- Emits 8 pipeline outputs via Set-AzLocalPipelineOutput: total_count,
  created_count, updated_count, already_in_sync_count, skipped_count,
  failed_count, whatif_count, results_json_path.
- Emits a markdown step summary (Settings table + Result breakdown +
  collapsible per-cluster details) via Add-AzLocalPipelineStepSummary.
- Honours -WhatIf via $WhatIfPreference and -Force via [switch] propagation.

YAML reductions:
- GitHub Actions Step.2: 417 → 214 lines (49% reduction).
- Azure DevOps Step.2: 376 → 165 lines (56% reduction).
  Install task now calls Add-AzLocalPipelineVersionBanner (Public).
  Apply task collapses to a single Set-AzLocalClusterUpdateRingTagFromCsv
  call wired to the banner step's installed_module_version output for
  the run-footer attribution line.

Module: psd1 NestedModules + FunctionsToExport + psm1 Export-ModuleMember
updated. Module now exports 43 functions (was 42).

Tests: +11 Pester tests under
  Tests/AzLocal.UpdateManagement.Tests.ps1
  #region v0.8.5: Set-AzLocalClusterUpdateRingTagFromCsv (Step.2 thin-YAML port)
covering CSV-missing fail-fast, column validation, empty-results JSON
sidecar, tally counts (Created/Updated/AlreadyInSync/Skipped/Failed/WhatIf),
PassThru shape, default OutputDirectory selection, and WhatIfPreference
propagation. BS7 "exactly 42 functions" assertions bumped to 43.

Validated: Full Pester suite — Passed=935 Failed=0 Skipped=1 Total=936
Duration=00:01:24.
Collapses ~960 lines of inline run/script blocks across the GitHub
Actions and Azure DevOps Step.7_monitor-updates.yml pipelines into
a single Public cmdlet call. The cmdlet:

- Reuses Get-AzLocalClusterInventory + Get-AzLocalUpdateRuns
  (with -PassThru -SkipSideloadedReset) for the fleet query in both
  scopes ('all' via inventory, 'by-update-ring' via tag).
- Reuses New-AzLocalPipelineJUnitXml (Private), Set-AzLocalPipelineOutput,
  Add-AzLocalPipelineStepSummary and Get-AzLocalPipelineHost - the same
  thin-YAML primitives Step.2/8/9 already use.
- Classifies each run row with elapsed durations, per-step warn/crit
  thresholds (default 2h/4h), overall warn/crit/skull thresholds
  (default 24h / 3d / 6d), step-error signal (progressStatus='Error'
  while State='InProgress'), recent-failure window (default 24h),
  unresolved-failure flag, severity score, and portal URLs.
- Writes update-monitor.csv (sorted by SeverityScore desc) and
  update-monitor.xml (JUnit; one <testcase> per in-flight run + one
  per unresolved-failed run) into -OutputDirectory.
- Emits 6 step outputs via Set-AzLocalPipelineOutput: in_flight,
  long_running, long_running_step, step_errored, recent_failures,
  unresolved_failures (all lowercase snake_case to match Step.2/8/9
  contract; lowercase is REQUIRED on ADO and case-insensitive on GH).
- Emits markdown step summary (status badge + scope/threshold line +
  metric table + 'In-flight runs' + 'Failed runs (unresolved)'
  tables + action-required / healthy footer) via Add-AzLocalPipelineStepSummary.
- Honours -Now for deterministic testing; -PassThru returns counts,
  artifact paths, and the enriched Rows array.

YAML reductions:
- GitHub Actions Step.7: 663 -> 225 lines (66% reduction).
  Install step uses Add-AzLocalPipelineVersionBanner (Public).
  jobs.monitor.outputs map lowercased to match new step outputs.
- Azure DevOps Step.7:   649 -> 187 lines (71% reduction).
  Install task uses Add-AzLocalPipelineVersionBanner (Public).
  Snapshot task collapses to a single Export-AzLocalUpdateRunMonitorReport
  call wired to the banner step's installed_module_version output.

Module: psd1 NestedModules + FunctionsToExport + psm1 Export-ModuleMember
updated. Module now exports 44 functions (was 43).

Tests: +11 Pester tests in Tests/AzLocal.UpdateManagement.Tests.ps1
  #region v0.8.5: Export-AzLocalUpdateRunMonitorReport (Step.7 thin-YAML port)
covering:
  1. Empty fleet -> all-zero outputs + idle status badge.
  2. Single in-flight within thresholds -> HEALTHY badge, no chips.
  3. Per-step warn: step elapsed > LongRunningStepHours -> warn chip
     and LongRunningStepCount=1.
  4. Step error (progressStatus=Error, State=InProgress) -> StepErroredCount=1
     and elevated SeverityScore + JUnit 'StepError' failure type.
  5. Unresolved failure (State=Failed) -> UnresolvedFailureCount=1 +
     JUnit 'RecentFailure' failure type.
  6. RecentFailureWindowHours=0 disables recent flag but unresolved
     still surfaces.
  7. Scope='by-update-ring' skips Get-AzLocalClusterInventory and
     queries by tag (Should -Invoke ... -Times 0 -Exactly).
  8. CSV is sorted by SeverityScore descending (worst first).
  9. JUnit XML is well-formed; one testcase per in-flight + per
     unresolved failure.
 10. Markdown step summary contains required headings, metric table,
     and footer module version.
 11. Defaults OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY when
     on Azure DevOps host.

Function-count assertions bumped 43 -> 44.

Validated: Full Pester suite -> Passed=946 Failed=0 Skipped=1
Total=947 Duration=00:00:57.
Port Step.8 fleet update status pipeline (GitHub Actions + Azure DevOps)
to the thin-YAML pattern. The fat ~1,200-line inline run: blocks are
condensed to a single Public cmdlet invocation per platform.

Module changes
- Public/Export-AzLocalFleetUpdateStatusReport.ps1: new 1,024-line cmdlet
  with 3 parameter sets (all clusters / by-update-ring / file overrides),
  3 JUnit testsuites (Fleet Version Distribution, AzureLocalFleetUpdateStatus,
  Update Run History and Error Details), markdown step summary, PassThru
  with 35 properties. Registered in psd1/psm1.
- Private/New-AzLocalPipelineJUnitXml.ps1: extended JUnit emitter to
  surface <properties><property name=... value=... /></properties> at both
  suite and testcase levels, with XML-escaping for special chars.

Pipeline porting
- Automation-Pipeline-Examples/github-actions/Step.8_fleet-update-status.yml
  1,217 -> 332 lines
- Automation-Pipeline-Examples/azure-devops/Step.8_fleet-update-status.yml
  1,210 -> 324 lines

Tests
- 12 new Step.8 cmdlet tests via InModuleScope + Mock fixtures (priority
  cascade, manifest fallback, IncludeUpdateRuns gating, ADO output dir
  resolution, by-update-ring parameter forwarding, PassThru shape).
- 3 new JUnit Properties helper tests (suite/testcase emit, XML-escape).
- Removed 3 stale fat-yml-content Describe blocks (132 lines) whose
  assertions were string-matches against the old inline run: blocks.
- BS7 expected function count: 44 -> 45.

Pester: 951 passed / 0 failed / 1 skipped / 59s.
Port Step.5_assess-update-readiness.yml (GH + ADO) to the thin-YAML pattern. The ~280-line inline 'Run readiness + blocking health checks' block is now the Export-AzLocalClusterUpdateReadinessReport Public cmdlet.

Cmdlet behavior (mirrors v0.8.4 yml byte-for-byte):
* Resolves OutputDirectory ('./artifacts' on GH/Local; BUILD_ARTIFACTSTAGINGDIRECTORY on ADO).
* Get-AzLocalClusterInventory -PassThru for ResourceId -> UpdateRing map.
* Scope=all + empty inventory short-circuits with IDLE markdown + zero outputs.
* Get-AzLocalClusterUpdateReadiness called twice (CSV + JUnit XML) to preserve the dorny/test-reporter contract.
* Test-AzLocalClusterHealth -BlockingOnly called twice (CSV + JUnit XML).
* 3-bucket model: ReadyForUpdate / UpToDate / NotReady.
* CriticalCount sum + clustersWithCritical count from PassThru rows.
* Merges readiness.xml + health-blocking.xml into assess-readiness.xml under <testsuites name='Update Readiness Assessment'>.
* Emits 8-section markdown step summary (header tile, action banner, summary counts, Not-Ready table, Critical-health table, per-UpdateRing pivot, all-clusters detail, cross-links).
* Emits 2 step outputs via Set-AzLocalPipelineOutput: not_ready, critical_failures (lowercase per v0.8.5 convention).

YAML shrink:
* github-actions/Step.5_assess-update-readiness.yml: 487 -> 213 lines.
* azure-devops/Step.5_assess-update-readiness.yml: 454 -> 195 lines.
* Install step uses Add-AzLocalPipelineVersionBanner (same as Steps 0/1/2/7/8).
* Job-level outputs renamed NOT_READY/CRITICAL_FAILURES -> not_ready/critical_failures to match cmdlet emit.

Registration:
* psd1: NestedModules + FunctionsToExport.
* psm1: Export-ModuleMember allowlist.
* Tests.ps1: BS7 expected function count 45 -> 46, expectedFunctions list.

Tests added (Describe 'Thin-YAML Step.5: Export-AzLocalClusterUpdateReadinessReport', 9 cases):
* Empty inventory short-circuit (IDLE markdown + zero outputs + PassThru zero counts).
* Scope=all forwards ClusterResourceIds.
* Scope=by-update-ring forwards ScopeByUpdateRingTag + UpdateRingValue.
* 3-bucket counts (1 ready, 1 uptodate, 1 notready) + criticalFindings sum.
* Combined XML merge under 'Update Readiness Assessment'.
* PassThru exposes all 13 documented properties.
* ADO host resolves OutputDirectory to BUILD_ARTIFACTSTAGINGDIRECTORY.
* Per-UpdateRing pivot section when >1 ring in scope.
* OK header tile when all clusters ready and zero Critical.

Pester: 960 passed / 0 failed / 1 skipped.
New cmdlet wraps the ~255-line "Collect Fleet Connectivity Data" inline
block in both Step.4_fleet-connectivity-status.yml platform files.

Reuses existing helpers:
- Get-AzLocalFleetConnectivityStatus -ExportPath -PassThru (data collection)
- New-AzLocalFleetConnectivityStatusSummary -FromObjects (no CSV round-trip)
- New-AzLocalPipelineJUnitXml (shared XML emitter, Properties param)
- Set-AzLocalPipelineOutput / Add-AzLocalPipelineStepSummary
- Add-AzLocalPipelineVersionBanner / Get-AzLocalPipelineHost

YAML changes:
- Step.4 GH and ADO workflows now invoke the cmdlet and emit 12 lowercase
  snake_case step outputs (total_clusters, connected_clusters,
  disconnected_clusters, total_arc_machines, connected_arc_machines,
  not_connected_arc_machines, expired_arc_machines, total_nics,
  total_arbs, online_arbs, offline_arbs, critical_count).
- ITSM downstream blocks preserved unchanged on both platforms.
- Step.4 BS guard rewritten for v0.8.5 (cmdlet-only smoke check).

Renderer hardening (New-AzLocalFleetConnectivityStatusSummary):
- FromCsvReports branch: rewrote ` = if (Test-Path) { @() } else { @() }`
  to plain assignment form. Under PS 5.1 strict mode + InModuleScope the
  if-as-expression silently left ` = `, causing
  `@( | Where-Object { .ClusterId })` to throw
  "property 'ClusterId' cannot be found".
- Cluster node-count rollup: replaced `Measure-Object -Property NodeCount
  -Sum` (throws under strict mode when the pipeline is empty or rows
  lack the property) with an explicit foreach loop that probes
  `.PSObject.Properties['NodeCount']` + `[int]::TryParse`.

Tests:
- 10 new Pester It blocks under "Thin-YAML Step.4:
  Export-AzLocalFleetConnectivityStatusReport".
- Test data pscustomobject literals now include NodeCount / Location /
  SubscriptionId / ResourceGroup so the renderer's table-building code
  paths (`.NodeCount` etc.) don't strict-mode-fault on minimal rows.

Module manifest + .psm1:
- NestedModules and FunctionsToExport include
  Export-AzLocalFleetConnectivityStatusReport.
- Export-ModuleMember allowlist updated.

Also fixes the v0.8.5 Step.5 Combined-XML test:
- Pre-creates readiness.xml and health-blocking.xml inside the test
  BeforeEach (Pester Mock bodies cannot reliably write to ``
  inside `InModuleScope` because Pester rebinds parameter scopes).
- Mocks now just return the row arrays; the cmdlet's merge step reads
  the pre-staged XML files.

Test summary: Passed=970 Failed=0 Skipped=1.
Condenses ~430 inline lines across the GitHub Actions and Azure DevOps
Step.3_apply-updates-schedule-audit.yml scaffolds into a single Public
cmdlet that orchestrates the existing schedule-coverage advisor and
calendar helpers and writes both the 4 ./reports/schedule-coverage-*
artifacts and the 12 lowercase step outputs.

Shared helpers reused (no behaviour duplication):
- Get-AzLocalPipelineHost, Set-AzLocalPipelineOutput
- Add-AzLocalPipelineStepSummary, Add-AzLocalPipelineVersionBanner
- New-AzLocalPipelineJUnitXml (multi-suite via -Suites)
- Test-AzLocalApplyUpdatesScheduleCoverage (-View Audit/Matrix/Recommend)
- Get-AzLocalApplyUpdatesScheduleConfig (.SchemaVersion, allow-list shape)
- Get-AzLocalApplyUpdatesScheduleCycleCalendar (-AsMarkdown -IncludePerRingSummary)

Fixes v0.8.4 Cycle Calendar silent-drop regression: the calendar now
renders UNCONDITIONALLY whenever -SchedulePath is supplied, eliminating
the v0.8.4 hasIssues-gate that silently dropped the calendar on
clean-fleet runs.

Also fixes a silent format-string bug pattern discovered during testing:
.Add(fmt -f a, b, c) inside a method call resolves the commas as
ARGUMENT SEPARATORS for the method, not as the -f operator arglist - so
-f only ever sees the first argument and throws "Index ... must be ...
less than the size of the argument list". Wrap the -f expression in an
extra paren pair when used inside a method invocation. Saved to user
memory for future sessions.

11 new Pester tests in Thin-YAML Step.3:
Export-AzLocalApplyUpdatesScheduleAudit Describe block covering schema
v1 vs v2 allow-list rendering, cycle calendar unconditional render,
zero-row JUnit placeholder testcase, hasIssues recommendation prepending,
dual JUnit suite emission (Schedule + Cron), schema v2 per-row allow-list,
sched-path arg passthrough, output count, and step-output emission.
Pre-existing yaml-validator tests rewritten to assert against the cmdlet
source (content moved during the thin-YAML refactor).

Function count 47 -> 48.

Tests: 979 passed, 0 failed, 1 skipped (Pester v5.7.1).
Adds a regression Pester test under "Thin-YAML Step.7:
Export-AzLocalUpdateRunMonitorReport" that asserts both the Cluster
cell and the Update cell of the "In-flight runs (sorted by severity
score, worst first)" markdown table are rendered as

    <a href="https://portal.azure.com/..." target="_blank" rel="noopener">name</a>

so operators clicking through to the portal blade from a pipeline
step summary keep the pipeline tab open and do NOT lose their place
in the workflow run.

The cmdlet already emits target=_blank rel=noopener on lines 557-558
(in-flight table) and 583-584 (Failed runs table) since the v0.8.5
Step.7 port in commit d8c019f - this test simply guards against any
future refactor silently dropping the attribute. No production code
change.

Tests: 12 passed, 0 failed in the Step.7 subset.
Condenses ~600 lines of inline `run: |` PowerShell from each Step.9
fleet-health-status pipeline (GitHub Actions + Azure DevOps) into a
single Public cmdlet, applying the v0.8.5 thin-YAML pattern already in
use for Step.0/1/2/3/4/5/7/8.

New Public cmdlet
-----------------
Public/Export-AzLocalFleetHealthStatusReport.ps1
- Queries Get-AzLocalFleetHealthFailures -View Detail (one ARG call,
  optionally filtered by -Severity and -UpdateRingTag).
- Builds a SUMMARY roll-up in process via Group-Object FailureReason +
  Severity. Sort order: Critical-first, then ClusterCount desc, then
  FailureCount desc.
- Queries Get-AzLocalFleetHealthOverview for the per-cluster rollup.
- Writes the standard CSV+JSON artefact bundle: detail / summary /
  overview pairs, plus the JUnit XML and the markdown step summary.
- Emits a 2-suite JUnit XML ([JUnit Debug] Critical Health Failures /
  [JUnit Debug] Warning Health Failures) via the shared
  New-AzLocalPipelineJUnitXml helper. Each <testcase> carries the ITSM
  dedupe properties (ClusterResourceId / UpdateName=FailureReason /
  Status=Severity / Severity / ClusterPortalUrl / TargetResourceName /
  TargetResourceType / FailureReason) consumed by New-AzLocalIncident.
- Renders a 4-section markdown step summary via the shared
  Add-AzLocalPipelineStepSummary helper: KPI table / Fleet Health
  Overview (top 100 clusters with target=_blank portal-link hyperlinks
  and Unicode health glyphs) / Health Check Failures By Reason (top
  25 reasons, zipped portal-link hyperlinks) / Detailed Results
  (per-cluster collapsible <details>, capped at 100 clusters).
- Emits 8 lowercase snake_case step outputs via Set-AzLocalPipelineOutput:
  total_clusters, total_failures, critical_count, warning_count,
  distinct_reasons, overview_rows, healthy_clusters, total_in_sub.
- -PassThru returns a PSCustomObject with all bucket counts + file
  paths + DetailRows + SummaryRows + OverviewRowsData for unit tests
  and ad-hoc PowerShell callers.

Thin pipeline YAMLs
-------------------
Step.9 GitHub Actions: 758 -> 343 lines (-415 lines).
Step.9 Azure DevOps  : 753 -> 336 lines (-417 lines).

Both YAMLs now consist of:
  1. Install + drift-detection banner via Add-AzLocalPipelineVersionBanner
  2. Single Collect Fleet Health Status step that calls
     Export-AzLocalFleetHealthStatusReport with Scope / Severity /
     UpdateRing + InstalledModuleVersion
  3. Compute Artifact Timestamp
  4. Upload reports (90d retention on GH; PublishBuildArtifacts on ADO)
  5. Publish JUnit Diagnostic Results
  6. ITSM (unchanged from prior version)

The previous inline "Create Fleet Health Summary" / "Display Fleet
Health Summary" markdown-emission step is no longer needed - the
cmdlet writes to $GITHUB_STEP_SUMMARY (GH) / emits
##vso[task.uploadsummary] (ADO) via Add-AzLocalPipelineStepSummary.

Module registration
-------------------
- AzLocal.UpdateManagement.psd1: NestedModules + FunctionsToExport
- AzLocal.UpdateManagement.psm1: Export-ModuleMember

Tests
-----
- New Describe "Thin-YAML Step.9: Export-AzLocalFleetHealthStatusReport"
  (6 It blocks):
    * empty fleet -> zero counts, zero-valued step outputs,
      placeholder JUnit testcase
    * Critical+Warning mixed fleet -> correct bucket counts,
      both severity suites in JUnit, ITSM <properties> present,
      target=_blank portal-link hyperlinks in the markdown summary
    * Scope by-update-ring forwards UpdateRing as -UpdateRingTag to
      both source cmdlets (Should -Invoke -ParameterFilter)
    * Severity filter forwards to Get-AzLocalFleetHealthFailures
    * Azure DevOps host defaults OutputDirectory to
      BUILD_ARTIFACTSTAGINGDIRECTORY\reports
    * Summary view orders Critical-first then ClusterCount desc
- Bumped "Should export exactly 48 functions" -> 49 (both sites in
  the test file) so the manifest-load Describe block passes.

Test results
------------
- Thin-YAML Step.9 Describe: 6 passed, 0 failed
- Module: AzLocal.UpdateManagement Describe: 42 passed, 0 failed
Condenses the inline `run: |` PowerShell from each Step.6
readiness-gated apply-updates pipeline (GitHub Actions + Azure DevOps)
into six new Public cmdlets, applying the v0.8.5 thin-YAML pattern
already in use for Step.0/1/2/3/4/5/7/8/9.

New Public cmdlets (6)
----------------------
Public/Resolve-AzLocalPipelineUpdateRing.ps1
  - Owns the manual-vs-schedule-file ring resolution previously
    duplicated as ~80 lines of inline script in each pipeline.
  - Back-compat: returns ManualUpdateRing verbatim when -UseScheduleFile
    is not set; throws on empty manual ring + no schedule file.
  - Schedule-file path: parses YAML schedule via
    Get-AzLocalApplyUpdatesScheduleConfig, then calls
    Resolve-AzLocalCurrentUpdateRing -Schedule $cfg -Now $resolveAt
    where $resolveAt honours -ResolveForDateUtc (yyyy-MM-dd).
  - Emits RESOLVED_UPDATE_RING and RESOLVED_ALLOWED_UPDATE_VERSIONS
    as step outputs (same name on both hosts; this is intentional -
    consumed via $env:RESOLVED_UPDATE_RING in the next step on both).
  - No-row case uses per-host severity (GH=notice, ADO=warning).

Public/Export-AzLocalClusterReadinessGateReport.ps1
  - Owns the readiness scan + CSV/MD generation previously
    duplicated as ~250 lines of inline script.
  - Empty-ring short-circuit: emits zero counters, no scan.
  - Per-host step-output naming:
    * GH:  READY_COUNT / TOTAL_COUNT / NOT_READY_COUNT (UPPER_SNAKE)
    * ADO: ReadyCount / TotalCount / NotReadyCount (PascalCase) so
      the ADO YAML stageDependencies binding
      outputs[`readiness.ReadyCount`] resolves correctly.

Public/Invoke-AzLocalReadinessGatedClusterUpdate.ps1
  - Owns the per-cluster apply loop + JUnit XML + apply-results.json
    generation previously duplicated as ~300 lines of inline script.
  - Throws when the readiness CSV is missing the ClusterResourceId
    column (v0.7.62 contract preserved).
  - Per-host step-output naming:
    * GH:  SUCCEEDED / SKIPPED / FAILED / HEALTH_BLOCKED /
           SCHEDULE_BLOCKED / SIDELOADED_BLOCKED / EXCLUDED_BY_TAG
    * ADO: Succeeded / Skipped / Failed / HealthBlocked /
           ScheduleBlocked / SideloadedBlocked / ExcludedByTag
  - The seven-counter emitter is a regular scriptblock (NOT
    .GetNewClosure(), which would sever access to the private
    Set-AzLocalPipelineOutput function via SessionState).

Public/Add-AzLocalApplyUpdatesStepSummary.ps1
  - Owns the markdown apply-updates rollup previously emitted by
    ~150 lines of inline script.
  - Host-aware icons: Unicode on GH; GitHub-Markdown shortcodes on ADO.

Public/Add-AzLocalNoReadyClustersStepSummary.ps1
  - Owns the no-clusters-ready notice job summary previously
    emitted by ~40 lines of inline script.

Public/Invoke-AzLocalItsmTicketingFromArtifact.ps1
  - Owns the post-apply ITSM ticket emission previously duplicated
    as ~150 lines of inline script.
  - Short-circuits silently when ConfigPath or InputArtifactPath
    is missing (consumer ITSM config is opt-in).

Thin pipeline YAMLs
-------------------
Step.6 GitHub Actions: 964 -> ~480 lines (-484 lines).
Step.6 Azure DevOps  : 985 -> ~440 lines (-545 lines).

Both YAMLs now consist of:
  1. Install + drift-detection banner via Add-AzLocalPipelineVersionBanner
  2. Single Resolve Update Ring step -> Resolve-AzLocalPipelineUpdateRing
  3. Single Check Readiness step -> Export-AzLocalClusterReadinessGateReport
  4. Compute Artifact Timestamp
  5. Upload readiness-report artifact
  6. Single Apply Updates step -> Invoke-AzLocalReadinessGatedClusterUpdate
  7. Single Summary step -> Add-AzLocalApplyUpdatesStepSummary
  8. ITSM (Invoke-AzLocalItsmTicketingFromArtifact)
  9. No-clusters-ready job -> Add-AzLocalNoReadyClustersStepSummary

The pipeline UI / step outputs / artifact contents / summary text are
unchanged byte-for-byte (per-host naming/icons are preserved).

Module registration
-------------------
- AzLocal.UpdateManagement.psd1: NestedModules + FunctionsToExport
  bumped from 49 to 55.
- AzLocal.UpdateManagement.psm1: Export-ModuleMember updated.

Tests
-----
- Bumped "Should export exactly 49 functions" -> 55 (both sites).
- Appended the 6 new cmdlet names to expectedFunctions.
- Replaced 4 broken inline-script-grep It blocks in the existing
  "v0.8.4 Step.6 Enhancement D" Context with thin-cmdlet invocation
  guards under a renamed "v0.8.5 Step.6 thin-YAML" Context.
- Replaced 6 broken inline-script-grep It blocks in the
  "v0.8.5 Step.6 manual schedule-file input" Context with
  thin-cmdlet invocation guards for Resolve-AzLocalPipelineUpdateRing
  on GH + ADO.
- New Describe blocks (parameter shape + per-host short-circuit guards)
  for all 6 new Public cmdlets.

Test results
------------
- Pester suite: 1069 passed, 0 failed, 1 skipped (~4m38s).
CHANGELOG.md (v0.8.5 entry)
---------------------------
- Reworded lead to reflect the full scope: original 0.8.5 Step.3
  cycle-calendar refactor PLUS the subsequent thin-YAML port across
  all 10 Step pipelines (Step.0 - Step.9, GH + ADO = 20 YAML files).
- Updated total export count: 35 (v0.8.4) -> 55 (v0.8.5).
- New section "Thin-YAML refactor across all 10 Step pipelines":
  - Goal: condense duplicated inline run: blocks into single Public
    cmdlets shared between GH + ADO.
  - Lists all 14 new Public cmdlets by Step (Step.6 has 6, others 1).
  - Documents per-host step-output naming (UPPER_SNAKE on GH,
    PascalCase on ADO for stageDependencies bindings).
  - Documents per-host icon style (Unicode on GH, GitHub-Markdown
    shortcodes on ADO).
  - Quantifies the line reduction (~9,000 lines removed across 20
    templates; Step.6 alone -484 lines GH / -545 lines ADO).
  - Calls out maintenance benefits: single source of truth,
    unit-testable, faster CI signal, cleaner diffs, discoverability
    via Get-Help, reusable shared helpers.
  - Documents the .GetNewClosure() / SessionState bug shaken out
    during testing and how it was fixed.
  - Records the final Pester baseline: 1069 passed, 0 failed,
    1 skipped, ~4m38s.
  - Confirms full backwards compatibility for consumer pipelines.

README.md (What's New in v0.8.5)
--------------------------------
- Reworded lead paragraph to add the thin-YAML port + 35 -> 55
  export-count delta.
- New item 8 in the numbered list summarising the thin-YAML port
  benefits with a pointer to the full CHANGELOG section.

No code or test changes in this commit - documentation only.
Adds .github/workflows/AzLocal.UpdateManagement-pester.yml - first CI
test workflow in the repository. Runs the AzLocal.UpdateManagement
Pester unit suite on every PR + push to main that touches the module.

Scope
-----
- Path-filtered to AzLocal.UpdateManagement/** + the workflow file
  itself, so doc-only or other-module PRs do not burn runner minutes.
- pull_request types: opened, synchronize, reopened (no draft churn).
- workflow_dispatch added so the workflow can be re-run on demand.
- Concurrency group cancels in-progress runs when a new commit lands.
- 20-minute job timeout (full suite is ~5 minutes locally).

Runner
------
- windows-latest is required so the suite exercises the same Windows
  PowerShell 5.1 + .NET 4.x surface that the published module targets.
- Pester 5.5.0+ installed at CurrentUser scope if not already present.
- No Az.* modules installed - the unit suite uses mocks for all az
  CLI calls (validated locally: 1069 passed, 0 failed, 1 skipped).

Test execution
--------------
- Filter.ExcludeTag = @('Live') keeps the durable Live-Integration
  suite out of CI (it needs az login + the AdaptiveCloudLab
  subscription and is gated by -IncludeLive in Invoke-Tests.ps1
  locally).
- TestResult.OutputFormat = 'JUnitXml' (Pester 5.4+) for compatibility
  with dorny/test-reporter's jest-junit reporter.
- Summary written to Tests/TestResults/summary.txt; per-failure lines
  to failures.txt; both uploaded as an artifact (14-day retention).
- Job fails when result.FailedCount > 0.

PR check publishing
-------------------
- dorny/test-reporter@v1 publishes the JUnit XML as a checks-API PR
  status, surfacing failed test names directly in the PR check tab.
- Only runs on pull_request events. checks: write + pull-requests:
  write permissions granted at the job level.
- Known limitation: GITHUB_TOKEN on fork PRs lacks checks: write, so
  the reporter step will be skipped (the test job itself still gates
  the merge). Not a concern for this single-owner repository.

Why now
-------
Companion to PR #74 (v0.8.5 thin-YAML port). The thin-YAML refactor
moved ~9,000 lines of inline PowerShell out of YAML run: blocks and
into Public cmdlets covered by 1069 Pester tests. Adding CI gating
locks in that investment: future PRs cannot regress the suite without
the failing tests showing up as a red PR check.

Easy to extend later: clone-and-rename this workflow for the
AzLocal.DeploymentAutomation and AzureLocalVM modules once their
suites are equally hermetic.
Three fixes to make the new CI workflow (run 27282472327 = 7 failed,
30 NotRun cascade) go green. None of the failures touched v0.8.5
thin-YAML code - all three are pre-existing test brittleness that
only surfaces on GitHub-hosted Windows runners.

1. Tests: Copy-AzLocalPipelineExample + Copy-AzLocalItsmSample
   ---------------------------------------------------------
   Symptom: 5 of 7 failures. `Should -Be (Join-Path $dest 'subfolder')`
   asserted against the function's returned `.FullName` produced:
     Expected: 'C:\Users\RUNNER~1\AppData\Local\Temp\azlocal-cpe-...'
     Got    : 'C:\Users\runneradmin\AppData\Local\Temp\azlocal-cpe-...'
   Root cause: $env:TEMP on GitHub-hosted Windows runners returns the
   8.3 short-name (`RUNNER~1`) because `runneradmin` exceeds the
   8-character limit. Local dev boxes return the long form. The
   functions resolve to long form internally; the test fixtures were
   keeping the short form.
   Fix: resolve $env:TEMP via (Get-Item -LiteralPath $env:TEMP).FullName
   inside both BeforeAll blocks before using it to build the test root.
   (Get-Item normalises 8.3 short-name segments to long form, where
   [IO.Path]::GetFullPath would not.)
   No production code touched; assertions unchanged.

2. Workflow: install powershell-yaml on the runner
   ------------------------------------------------
   Symptom: 1 of 7 failures - `ITSM: Get-AzLocalItsmConfig normalises
   non-Hashtable YAML dictionaries`. The test mocks ConvertFrom-Yaml,
   but Mock requires the target command to exist in the module's
   session scope. powershell-yaml isn't preinstalled on
   windows-latest runners, so ConvertFrom-Yaml is unresolvable.
   Fix: new workflow step `Install powershell-yaml` between Pester
   install and the smoke test. Idempotent: skips install if the
   module is already available.

3. Workflow: replace dorny/test-reporter with EnricoMi
   ----------------------------------------------------
   Symptom: dorny/test-reporter@v1 emitted
     `TypeError: stackTrace.split is not a function`
   when parsing Pester's JUnit XML via its jest-junit reporter.
   Root cause: Pester serialises stackTrace as a multi-line string
   array; jest-junit produces a single string; the reporter assumes
   the latter.
   Fix: switch to EnricoMi/publish-unit-test-result-action/windows@v2
   which understands Pester JUnit shape natively. Comment-mode is
   disabled (status is already on the PR via the workflow check).

Also addresses the question raised on the PR thread: 1069/0/1 was
the LOCAL baseline; CI runner is 1039 net tests (excluding 30
that were NotRun due to the cascade from the 5 path failures).
With these three fixes the CI count should match local.

Expected next CI run on PR #74: 1069 passed / 0 failed / 1 skipped.
…e for 7 Export-* cmdlets

CI fix (Publish test report check)
----------------------------------
Pester writes pester-junit.xml via Windows PowerShell 5.1's Set-Content
-Encoding UTF8, which emits a 3-byte UTF-8 BOM. EnricoMi's lxml-based
parser rejects the file with:
  ERROR - lxml.etree.XMLSyntaxError: Start tag expected, '<' not found,
          line 1, column 1
  Error: Error processing result file: missing toplevel element (line 0)

The Pester job itself was green (1069/0/1), but the separate
"AzLocal.UpdateManagement Pester" check (created by EnricoMi when it
fails to parse the report) showed red on PR #74.

Fix: add a workflow step right after the Pester step that re-writes
pester-junit.xml without the BOM, using the BOM-aware UTF-8 reader and
the BOM-less UTF-8 writer. Idempotent (no-op if the file already has
no BOM).

Live-Integration coverage for v0.8.5 thin-YAML cmdlets
------------------------------------------------------
Adds one Describe block to Tests/Live-Integration.Tests.ps1 with one
It per non-destructive Step.N Export-* cmdlet (7 in total):

  Step.0  Export-AzLocalAuthValidationReport
  Step.3  Export-AzLocalApplyUpdatesScheduleAudit
  Step.4  Export-AzLocalFleetConnectivityStatusReport
  Step.5  Export-AzLocalClusterUpdateReadinessReport
  Step.6  Export-AzLocalClusterReadinessGateReport (empty-ring short-circuit only)
  Step.7  Export-AzLocalUpdateRunMonitorReport
  Step.8  Export-AzLocalFleetUpdateStatusReport
  Step.9  Export-AzLocalFleetHealthStatusReport

Each It runs the cmdlet against a per-test `C:\Users\nebird\AppData\Local\Temp\azlocal-live-exp-<guid>\<slug>-<guid>`
directory with -PassThru, then asserts:
  - the documented artifact files exist
  - those files are non-empty
  - the PassThru payload exposes the documented top-level properties

All artifacts are cleaned up in AfterAll. Read-only: no writes to
Azure. Tagged 'Live'; skipped by default in CI (Invoke-Tests.ps1's
-ExcludeTag 'Live' filter); auto-skipped when `az` isn't logged into
the AdaptiveCloudLab subscription.

Destructive Step.6/Step.6.5 cmdlets (Invoke-AzLocalReadinessGated-
ClusterUpdate, Set-AzLocalClusterUpdateRingTagFromCsv) are explicitly
deferred - they need a scratch-cluster fixture pattern that's tracked
as follow-up.
@NeilBird
NeilBird merged commit 629c501 into main Jun 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant