Preflight: Stop Before the First Production Change
📦 Run it yourself — this post’s examples are in
companion/tests/Preflight/andcompanion/config/pester.preflight.ps1.
The safest production change is the one that stops before doing anything.
That sounds unambitious until you consider how many incidents begin with a condition that was knowable in advance:
- Wrong target
- Wrong account
- Unsupported version
- Missing approval
- Existing processing lock
- Unfinished prior attempt
- Invalid input
- Unavailable quarantine path
- No tested recovery package
A preflight gate turns those conditions into explicit evidence before the first state change.
What you will learn
- What belongs in preflight instead of unit or post-deployment testing
- How to design checks that remain read-only
- How to produce one result object with all failed prerequisites
- How to run Pester as a required gate
- Why skipped and not-run prerequisites must stop the operation
- How to separate “target not ready” from “test system failed”
Preflight is not a health dashboard
Preflight has one immediate decision:
May this specific operation begin against this specific target now?
It is not a general monthly scorecard. It is not a replacement for monitoring. It is not an invitation to test every setting on the server.
A good preflight check is directly tied to the operation.
For the Partner Feed Guardian, the gate should prove:
- The partner is approved
- The schema is supported
- The batch ID is new
- The manifest signature is valid
- The payload inventory matches the manifest
- The effective date has arrived
- No processing lock remains
- Quarantine is available
Return all check results
Operators need more than the first failure.
function Test-PfgPreflight {
[CmdletBinding()]
param(
[psobject]$Batch,
[string[]]$SupportedSchemas,
[string[]]$ProcessedBatchIds = @(),
[bool]$PartnerApproved = $true,
[bool]$ManifestSignatureValid = $true,
[bool]$ProcessingLockClear = $true,
[bool]$QuarantineAvailable = $true,
[datetime]$Now = (Get-Date)
)
$checks = @(
[pscustomobject]@{
Name = 'PartnerApproved'
Passed = $PartnerApproved
}
[pscustomobject]@{
Name = 'SchemaSupported'
Passed = $Batch.SchemaVersion -in $SupportedSchemas
}
[pscustomobject]@{
Name = 'BatchUnique'
Passed = $Batch.BatchId -notin $ProcessedBatchIds
}
[pscustomobject]@{
Name = 'ManifestSignatureValid'
Passed = $ManifestSignatureValid
}
[pscustomobject]@{
Name = 'ProcessingLockClear'
Passed = $ProcessingLockClear
}
[pscustomobject]@{
Name = 'QuarantineAvailable'
Passed = $QuarantineAvailable
}
)
[pscustomobject]@{
BatchId = $Batch.BatchId
Passed = -not ($checks.Passed -contains $false)
Checks = $checks
FailedChecks = @($checks | Where-Object { -not $_.Passed })
}
}This function returns a complete preflight record. Pester can assert each required expectation, and the deployment wrapper can store the same object as evidence.
Write checks that explain failure
Describe 'Partner batch preflight' `
-Tag 'Gate.Preflight', 'Environment.Lab', 'Risk.ReadOnly' {
It 'passes only when every required prerequisite is true' {
$result = Test-PfgPreflight `
-Batch $batch `
-SupportedSchemas @('2.1') `
-ManifestSignatureValid $true `
-ProcessingLockClear $true `
-QuarantineAvailable $true `
-Now ([datetime]'2026-07-17T12:00:00Z')
$result.Passed | Should-BeTrue
$result.FailedChecks | Should-BeCollection @()
}
It 'identifies an invalid manifest signature' {
$result = Test-PfgPreflight `
-Batch $batch `
-SupportedSchemas @('2.1') `
-ManifestSignatureValid $false `
-ProcessingLockClear $true `
-QuarantineAvailable $true `
-Now ([datetime]'2026-07-17T12:00:00Z')
$result.Passed | Should-BeFalse
$result.FailedChecks.Name |
Should-BeCollection @('ManifestSignatureValid')
}
}The first test protects the aggregate decision. The second protects the diagnostic result.
In a real preflight suite, some inputs come from actual read-only provider calls rather than parameters. Keep those calls clearly separated so a dependency error does not masquerade as an ordinary failed prerequisite.
A failed check is different from a broken check
These are not the same:
ManifestSignatureValid = false
and:
The signature service could not be reached
The first is evidence that the batch must not proceed.
The second means the gate could not obtain evidence. The safe operational decision is still to stop, but ownership and diagnosis differ.
Represent the distinction:
[pscustomobject]@{
Name = 'ManifestSignatureValid'
Outcome = 'Error'
Passed = $false
Detail = 'Signature service timeout'
Owner = 'Security Platform'
}Do not translate every dependency failure into $false and erase the reason.
Preflight should remain read-only
A preflight gate may:
- Read configuration
- Query current state
- Validate credentials without making a business change
- Verify the target and identity
- Confirm a recovery artifact exists
- Confirm an isolated path is writable using an approved nonbusiness probe
It should not begin the actual operation.
Be careful with “writable” tests. Creating and deleting a file, ticket, or database record is a state change. That may be acceptable in a dedicated probe area, but call it what it is and use the safeguards from the production-probe article.
For many preflight checks, a permission query or dedicated provider validation method is preferable.
Run the preflight with an explicit configuration
Import-Module Pester -RequiredVersion 6.0.0
$config = New-PesterConfiguration
$config.Run.Path = "$PSScriptRoot/../tests/Preflight"
$config.Run.PassThru = $true
$config.Output.Verbosity = 'Detailed'
$config.Should.DisableV5 = $true
$config.Filter.Tag = @('Gate.Preflight')
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = `
"$PSScriptRoot/../artifacts/preflight-results.xml"
$config.TestResult.OutputFormat = 'NUnitXml'
$config.Run.Exit = $falseWhy set Run.Exit to $false?
Because the wrapper will inspect the complete result object and apply a stricter gate policy than “did at least one assertion fail?”
Enforce completion, not merely absence of red tests
$result = Invoke-Pester -Configuration $config
$gateFailed =
$result.FailedCount -gt 0 -or
$result.SkippedCount -gt 0 -or
$result.InconclusiveCount -gt 0 -or
$result.NotRunCount -gt 0 -or
$result.FailedBlocksCount -gt 0 -or
$result.FailedContainersCount -gt 0
if ($gateFailed) {
throw 'Preflight did not complete successfully. Production change blocked.'
}A required prerequisite that was skipped because someone forgot a secret is not a pass.
A test file that failed during discovery is not a pass.
A BeforeAll that could not initialize is not a pass.
The gate needs successful evidence, not merely a lack of ordinary assertion failures.
Avoid optional checks inside required gates
Teams sometimes weaken a gate because one test is optional:
if ($result.SkippedCount -gt 3) {
throw 'Too many skipped tests'
}Now nobody knows which skipped tests were acceptable.
Instead, create separate profiles:
- Required preflight
- Advisory diagnostics
- Environment-specific optional checks
The required suite can stay strict.
Verify the exact target
A preflight should prove that the operation is pointed at the intended target.
It 'targets the approved production route' {
$target = Get-PfgTargetContext
$target.Environment | Should-BeString 'Production'
$target.TenantId | Should-BeString $expectedTenantId
$target.PartnerId | Should-BeString 'NORTHWIND'
}Do not rely only on a hostname that could be aliased or reused. Use stable identifiers where the provider offers them.
Verify the execution identity
It 'runs under the approved production identity' {
$identity = Get-PfgCurrentIdentity
$identity.Name |
Should-BeString 'CONTOSO\svc-partner-intake'
}A preflight run under your administrator account may pass access checks that tell you nothing about the scheduled operation.
Verify recovery before change
A rollback or recovery package should be tested before the deployment needs it.
For a module rollout, preflight may verify:
- Prior version package is available
- Package hash matches the approved artifact
- Recovery command parses and loads
- Current configuration is exported
- The rollback target is compatible
For batch processing, preflight may verify:
- Quarantine path exists
- Current routing configuration is captured
- Previous unfinished batch is reconciled
- Evidence store accepts records
The rule is:
If recovery requires an artifact, permission, or command, prove it before the operation removes your comfortable options.
Store the evidence
A preflight result should include:
- Gate name
- Target
- Identity
- Code version
- Pester version
- PowerShell version
- Start and finish times
- Test-result file
- Correlation or change ID
- Final decision
That record helps answer a critical incident question:
What did we believe was true immediately before the change?
What preflight proves
A complete preflight proves that the selected prerequisites were successfully evaluated and satisfied for the identified target, identity, code version, and time.
What it does not prove
It does not prove the change will succeed, the target will remain unchanged after the check, or the operation contains no defect. Preflight narrows avoidable risk. It does not predict the future.
Try it yourself
For one production operation, list every condition that should stop it before the first state change.
Group them into:
Target
Identity
Input
Starting state
Dependency
Approval
Timing
Recovery
Create a result object that reports every check, then place the required checks behind a Pester configuration that rejects skipped, inconclusive, not-run, block, and container failures.
Common mistakes
Using preflight as a generic server audit. Check only what is relevant to the operation and its recovery.
Changing production during a supposedly read-only gate. Isolate and declare any probe that creates state.
Running under the wrong identity. Access evidence must come from the account that will perform the work.
Treating unavailable evidence as success. A prerequisite that could not be checked must stop a required gate.
Checking for rollback after deployment fails. Prove recovery prerequisites before the first change.
Recap
Preflight gives Pester a direct operational role: grant or deny permission to begin.
A strong preflight is targeted, read-only by default, identity-aware, strict about incomplete execution, and connected to recovery.
Next up: Part 7 — Deployment Verification and Progressive Rollout. We will prove that the intended module—not merely some module—landed on a canary runner and can complete a controlled workload before the rollout continues.