Preflight: Stop Before the First Production Change

PesterForge · July 2026 · 8 min read

📦 Run it yourself — this post’s examples are in companion/tests/Preflight/ and companion/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:

A preflight gate turns those conditions into explicit evidence before the first state change.

What you will learn

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:

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:

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 = $false

Why 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:

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:

For batch processing, preflight may verify:

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:

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.


← All posts