Imaging chain: Stage-Dispatcher + PPKG reboot + unattended sync_intune

Replaces the single-session "cancel PPKG reboot and cram everything into
one autologon" flow with a staged chain where each reboot advances to the
next step automatically. The technician touches the keyboard 3 times total
(UNPLUG prompt, Y to reboot, Configure-PC selections).

New Stage-Dispatcher.ps1:
  Reads C:\Enrollment\setup-stage.txt and chains through:
    shopfloor-setup -> sync-intune -> configure-pc
  Each stage re-registers HKLM RunOnce so the dispatcher fires again on
  the next logon. Stage file is deleted when the chain completes.
  Transcript logged to C:\Logs\SFLD\stage-dispatcher.log.

  Stage "shopfloor-setup": runs Run-ShopfloorSetup.ps1 (which reboots via
    shutdown /r /t 10). Dispatcher advances stage to sync-intune in the
    ~10 second window before the machine goes down, re-registers RunOnce.

  Stage "sync-intune": launches Monitor-IntuneProgress.ps1 -Unattended.
    Exit 2 (pre-reboot done, user confirmed): dispatcher re-registers
    RunOnce and initiates shutdown /r /t 5. Stage stays at sync-intune so
    the monitor picks up post-reboot state on next boot.
    Exit 0 (post-reboot install complete): dispatcher chains directly to
    Configure-PC.ps1 in the same session, then deletes the stage file.

  Stage "configure-pc": runs Configure-PC.ps1 and deletes the stage file.
    Fallback entry point if the post-reboot chain was interrupted.

Modified run-enrollment.ps1:
  Removed the shutdown /a that canceled the PPKG reboot. Instead writes
  setup-stage.txt = "shopfloor-setup" and registers RunOnce for the
  dispatcher. PPKG reboot fires naturally (handles PendingFileRename
  operations like Zscaler rename and PPKG self-cleanup). Now tracked in
  the git repo at playbook/shopfloor-setup/run-enrollment.ps1.

Modified Monitor-IntuneProgress.ps1:
  New -Unattended switch. When set:
    Invoke-SetupComplete exits 0 without waiting for keypress.
    Invoke-RebootPrompt exits 2 without prompting or rebooting (dispatcher
    handles both). Manual sync_intune.bat usage (no flag) unchanged.
  RetriggerMinutes bumped from 3 to 5 (user request).

Modified startnet.cmd:
  Now also copies Stage-Dispatcher.ps1 from the PXE server to
  W:\Enrollment\Stage-Dispatcher.ps1 alongside run-enrollment.ps1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-04-10 09:55:00 -04:00
parent 7c26e10f7e
commit b13e34c05a
4 changed files with 275 additions and 1 deletions

View File

@@ -51,7 +51,13 @@
[CmdletBinding()]
param(
[int]$PollSecs = 30,
[int]$RetriggerMinutes = 3
[int]$RetriggerMinutes = 5,
# When set, the monitor exits with a status code instead of prompting
# or rebooting. Used by Stage-Dispatcher.ps1 to keep control of the
# imaging chain. Manual sync_intune.bat usage (no flag) stays interactive.
# exit 0 = post-reboot install complete, all milestones reached
# exit 2 = pre-reboot deployment done, reboot needed
[switch]$Unattended
)
# ============================================================================
@@ -547,6 +553,11 @@ function Invoke-SetupComplete {
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "The post-reboot DSC install phase is finished. The device is ready."
if ($Unattended) {
Write-Host "(Unattended mode - returning to dispatcher)"
exit 0
}
Wait-ForAnyKey
exit 0
}
@@ -562,6 +573,15 @@ function Invoke-RebootPrompt {
Write-Host "device-config.yaml and runs the per-app wrappers: Install-eDNC,"
Write-Host "Install-UDC, Install-VCRedists, Install-OpenText, and so on."
Write-Host ""
if ($Unattended) {
# Dispatcher mode: exit with code 2 so the dispatcher can set
# RunOnce before initiating the reboot itself.
Write-Host "Pre-reboot complete. Returning to dispatcher (exit 2)." -ForegroundColor Yellow
exit 2
}
# Interactive mode (manual sync_intune.bat): prompt and reboot directly.
Write-Host "Press Y to reboot now, or N to cancel: " -NoNewline -ForegroundColor Cyan
$ans = Read-SingleKey -ValidKeys @('Y', 'N')
Write-Host $ans

View File

@@ -0,0 +1,175 @@
# Stage-Dispatcher.ps1 - Chain controller for the shopfloor imaging pipeline.
#
# Reads C:\Enrollment\setup-stage.txt to know which step of the imaging
# process we're in, launches the right script, advances the stage, and
# re-registers itself via RunOnce so the next logon picks up the next step.
#
# STAGE CHAIN:
# "shopfloor-setup" -> Run-ShopfloorSetup.ps1 (installs apps, deferred org)
# Run-ShopfloorSetup reboots via shutdown /r /t 10;
# dispatcher has ~10 seconds to write the next stage
# and re-register RunOnce before the machine goes down.
#
# "sync-intune" -> Monitor-IntuneProgress.ps1 -Unattended
# Monitor exits 2 = pre-reboot Y pressed (no reboot yet)
# -> dispatcher re-registers RunOnce, initiates reboot
# Monitor exits 0 = post-reboot install complete
# -> dispatcher advances to configure-pc
#
# "configure-pc" -> Configure-PC.ps1 (startup items, machine number)
# -> dispatcher deletes stage file. Chain complete.
#
# (file missing) -> exit 0. Setup is done, nothing to do.
#
# ENTRY POINT:
# Registered as a RunOnce value by run-enrollment.ps1 after PPKG install.
# Each stage re-registers RunOnce for the next boot. RunOnce entries are
# consumed by Windows after execution, so no cleanup needed.
#
# LOGGING:
# Appends to C:\Logs\SFLD\stage-dispatcher.log via Start-Transcript.
$ErrorActionPreference = 'Continue'
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcriptPath = Join-Path $logDir 'stage-dispatcher.log'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}
Write-Host ""
Write-Host "================================================================"
Write-Host "=== Stage-Dispatcher.ps1 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
Write-Host "================================================================"
Write-Host "Running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# --- Paths ---
$stageFile = 'C:\Enrollment\setup-stage.txt'
$dispatcherPath = $MyInvocation.MyCommand.Path
$enrollDir = 'C:\Enrollment'
$shopfloorDir = Join-Path $enrollDir 'shopfloor-setup\Shopfloor'
$runOnceKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
# --- Stage file ---
if (-not (Test-Path -LiteralPath $stageFile)) {
Write-Host "No stage file found at $stageFile - setup complete, nothing to do."
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
$stage = (Get-Content -LiteralPath $stageFile -First 1).Trim()
Write-Host "Current stage: $stage"
# --- Helper: re-register this dispatcher to run at next logon ---
function Register-NextRun {
try {
Set-ItemProperty -Path $runOnceKey -Name 'ShopfloorSetup' `
-Value "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$dispatcherPath`"" `
-Type String -Force -ErrorAction Stop
Write-Host "RunOnce registered for next logon."
} catch {
Write-Warning "Failed to register RunOnce: $_"
}
}
# --- Stage dispatch ---
switch ($stage) {
'shopfloor-setup' {
Write-Host ""
Write-Host "--- Stage: shopfloor-setup ---"
$script = Join-Path $enrollDir 'Run-ShopfloorSetup.ps1'
if (-not (Test-Path -LiteralPath $script)) {
Write-Warning "Run-ShopfloorSetup.ps1 not found at $script"
break
}
# Run-ShopfloorSetup.ps1 calls shutdown /r /t 10 at the end, which
# gives us a ~10 second window after it returns to advance the stage
# and re-register RunOnce before the reboot fires.
& $script
Write-Host "Run-ShopfloorSetup.ps1 finished. Advancing stage to sync-intune."
Set-Content -LiteralPath $stageFile -Value 'sync-intune' -Force
Register-NextRun
Write-Host "Reboot imminent (initiated by Run-ShopfloorSetup.ps1)."
}
'sync-intune' {
Write-Host ""
Write-Host "--- Stage: sync-intune ---"
$monitor = Join-Path $shopfloorDir 'lib\Monitor-IntuneProgress.ps1'
if (-not (Test-Path -LiteralPath $monitor)) {
Write-Warning "Monitor-IntuneProgress.ps1 not found at $monitor"
break
}
# Launch the monitor in THIS console window (interactive - tech sees
# QR code + status table). -Unattended makes it exit with a status
# code instead of prompting/rebooting, so we keep control here.
Write-Host "Launching Intune sync monitor (unattended mode)..."
& $monitor -Unattended
$monitorExit = $LASTEXITCODE
Write-Host "Monitor exited with code: $monitorExit"
switch ($monitorExit) {
0 {
# Post-reboot install complete. All milestones reached.
# Chain directly to Configure-PC (same session, no reboot).
Write-Host "Post-reboot install complete. Launching Configure-PC..."
Set-Content -LiteralPath $stageFile -Value 'configure-pc' -Force
$configScript = Join-Path $shopfloorDir 'Configure-PC.ps1'
if (Test-Path -LiteralPath $configScript) {
& $configScript
} else {
Write-Warning "Configure-PC.ps1 not found at $configScript"
}
# Chain complete.
Write-Host "Imaging chain complete. Removing stage file."
Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue
}
2 {
# Pre-reboot deployment done. User confirmed reboot.
# Re-register RunOnce so we come back after the reboot to
# monitor the post-reboot install phase.
Write-Host "Pre-reboot phase complete. Rebooting..."
Register-NextRun
# Stage stays at 'sync-intune' - on next boot the monitor
# will detect post-reboot state and monitor until done.
shutdown /r /t 5
}
default {
Write-Warning "Unexpected monitor exit code: $monitorExit"
Write-Host "Re-registering for retry on next logon."
Register-NextRun
}
}
}
'configure-pc' {
Write-Host ""
Write-Host "--- Stage: configure-pc ---"
$configScript = Join-Path $shopfloorDir 'Configure-PC.ps1'
if (Test-Path -LiteralPath $configScript) {
& $configScript
} else {
Write-Warning "Configure-PC.ps1 not found at $configScript"
}
Write-Host "Imaging chain complete. Removing stage file."
Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue
}
default {
Write-Warning "Unknown stage: '$stage'. Removing stage file."
Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue
}
}
Write-Host ""
Write-Host "=== Stage-Dispatcher.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
try { Stop-Transcript | Out-Null } catch {}

View File

@@ -0,0 +1,78 @@
# run-enrollment.ps1
# Installs GCCH enrollment provisioning package via Install-ProvisioningPackage
# Called by FirstLogonCommands as SupportUser (admin) after imaging
$ErrorActionPreference = 'Continue'
$logFile = "C:\Logs\enrollment.log"
New-Item -ItemType Directory -Path "C:\Logs" -Force -ErrorAction SilentlyContinue | Out-Null
function Log {
param([string]$Message)
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "$ts $Message"
Write-Host $line
Add-Content -Path $logFile -Value $line
}
Log "=== GE Aerospace GCCH Enrollment ==="
# --- Find the .ppkg ---
$ppkgFile = Get-ChildItem "C:\Enrollment\*.ppkg" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $ppkgFile) {
Log "ERROR: No .ppkg found in C:\Enrollment\"
exit 1
}
Log "Package: $($ppkgFile.Name)"
# --- Set computer name to E<serial> ---
$serial = (Get-CimInstance Win32_BIOS).SerialNumber
$newName = "E$serial"
Log "Setting computer name to $newName"
Rename-Computer -NewName $newName -Force -ErrorAction SilentlyContinue
# --- Install provisioning package ---
Log "Installing provisioning package..."
try {
Install-ProvisioningPackage -PackagePath $ppkgFile.FullName -ForceInstall -QuietInstall
Log "Provisioning package installed successfully."
} catch {
Log "ERROR: Install-ProvisioningPackage failed: $_"
Log "Attempting fallback with Add-ProvisioningPackage..."
try {
Add-ProvisioningPackage -PackagePath $ppkgFile.FullName -ForceInstall -QuietInstall
Log "Provisioning package added successfully (fallback)."
} catch {
Log "ERROR: Fallback also failed: $_"
exit 1
}
}
# --- Set OOBE complete ---
Log "Setting OOBE as complete..."
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE" /v OOBEComplete /t REG_DWORD /d 1 /f | Out-Null
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE" /v SetupDisplayedEula /t REG_DWORD /d 1 /f | Out-Null
# --- Stage the imaging chain for next boot ---
# The PPKG schedules a reboot (PendingFileRenameOperations for Zscaler
# rename, PPKG self-cleanup, etc). Instead of canceling it and cramming
# Run-ShopfloorSetup into this same session, we let the reboot happen
# and register a RunOnce entry that fires Stage-Dispatcher.ps1 on the
# next autologon. The dispatcher reads setup-stage.txt and chains
# through: shopfloor-setup -> sync-intune -> configure-pc.
$stageFile = 'C:\Enrollment\setup-stage.txt'
$dispatcherPath = 'C:\Enrollment\Stage-Dispatcher.ps1'
$runOnceKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
Log "Writing stage file: shopfloor-setup"
Set-Content -LiteralPath $stageFile -Value 'shopfloor-setup' -Force
if (Test-Path -LiteralPath $dispatcherPath) {
Log "Registering RunOnce for Stage-Dispatcher.ps1"
Set-ItemProperty -Path $runOnceKey -Name 'ShopfloorSetup' `
-Value "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$dispatcherPath`"" `
-Type String -Force
} else {
Log "WARNING: Stage-Dispatcher.ps1 not found at $dispatcherPath - RunOnce not set"
}
Log "=== Enrollment complete. PPKG reboot will fire and Stage-Dispatcher picks up on next logon. ==="

View File

@@ -224,6 +224,7 @@ if errorlevel 1 (
goto copy_pctype
)
copy /Y "Y:\run-enrollment.ps1" "W:\run-enrollment.ps1"
copy /Y "Y:\shopfloor-setup\Stage-Dispatcher.ps1" "W:\Enrollment\Stage-Dispatcher.ps1"
REM --- Create enroll.cmd at drive root as manual fallback ---
> W:\enroll.cmd (