10 min read

A PowerShell script that outlives its author: parameters, dry run, logs and exit codes

A script run every night by Task Scheduler is a production service, even if it is a hundred lines long. How to write it so that a successor knows what it does and the scheduler knows whether it succeeded.

Fig. 1. The script checks its input before changing anything, can show changes without making them, and every outcome, including an error, ends with a log entry and an exit code.

On a file server, a scheduled task has been deleting old export files every night for three years. The author of the script moved to another department long ago. One night the script cannot find the directory, because the drive letter changed after a migration; it prints an error nobody sees and finishes. Task Scheduler shows a result of 0x0, meaning success. Two months later the export disk fills up, and nobody knows since when the script has not been working or which parameters it was run with.

A script that runs unattended is a production service, even if it is a hundred lines long. It needs the same things a service does: declared requirements, checked input, a dry-run mode, a log, a readable result and no passwords in the code. PowerShell provides most of this without extra work; you only have to use it.

The script skeleton

The script below deletes files older than a given number of days. The logic is deliberately simple; what matters is the scaffolding, which can be copied into any script run by the scheduler.

#Requires -Version 5.1
<#
.SYNOPSIS
    Deletes export files older than the given number of days.
.DESCRIPTION
    Run from Task Scheduler. Exit codes: 0 success,
    1 critical error, 2 some files were not deleted.
.EXAMPLE
    .\Remove-OldExports.ps1 -Path D:\Export -OlderThanDays 30 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$Path,

    [ValidateRange(1, 3650)]
    [int]$OlderThanDays = 30,

    [ValidateNotNullOrEmpty()]
    [string]$LogDirectory = 'C:\ProgramData\Scripts\Logs'
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# The log is written in -WhatIf mode as well
New-Item -ItemType Directory -Path $LogDirectory -Force -WhatIf:$false | Out-Null
$logFile = Join-Path $LogDirectory ('Remove-OldExports_{0:yyyyMMdd}.log' -f (Get-Date))

function Write-Log {
    param(
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR')][string]$Level = 'INFO'
    )
    $line = '{0:yyyy-MM-dd HH:mm:ss} [{1}] {2}' -f (Get-Date), $Level, $Message
    Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 -WhatIf:$false
    Write-Verbose $line
}

$exitCode = 0
try {
    Write-Log "Start: $Path, older than $OlderThanDays days, WhatIf: $WhatIfPreference"
    $cutoff = (Get-Date).AddDays(-$OlderThanDays)
    $files = @(Get-ChildItem -LiteralPath $Path -File | Where-Object LastWriteTime -lt $cutoff)
    Write-Log "Files to delete: $($files.Count)"

    foreach ($file in $files) {
        if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete file')) {
            try {
                Remove-Item -LiteralPath $file.FullName
                Write-Log "Deleted: $($file.FullName)"
            }
            catch {
                Write-Log "Not deleted: $($file.FullName): $($_.Exception.Message)" -Level WARN
                $exitCode = 2
            }
        }
    }
}
catch {
    Write-Log "Error: $($_.Exception.Message)" -Level ERROR
    $exitCode = 1
}
finally {
    Write-Log "Finished, exit code: $exitCode"
}
exit $exitCode

If the file contains non-ASCII characters, save it as UTF-8 with a BOM. Windows PowerShell 5.1 reads a file without a BOM in the system code page and turns those characters in messages into random symbols.

The header: requirements and help

#Requires checks its conditions before the first line runs: -Version 5.1 blocks execution on an older version, -Modules ActiveDirectory on a machine without the module, and -RunAsAdministrator in a session without elevated rights. The script then ends with one readable message instead of an error halfway through its work.

The help block with .SYNOPSIS, .DESCRIPTION and .EXAMPLE is documentation that does not drift away from the code. Get-Help .\Remove-OldExports.ps1 -Full displays it together with the parameter list, and the description of exit codes answers the first question of anyone who sees a result of 0x2 in the scheduler.

Parameters with validation

Validation attributes act before the script does anything. An invalid value stops the run with a message that says which parameter is the problem.

AttributeWhat it checksExample use
[Parameter(Mandatory)]the parameter must be supplied; under -NonInteractive, a missing one ends in an errordirectory path
[ValidateScript({ … })]any condition that returns true or falseTest-Path -LiteralPath $_ -PathType Container
[ValidateRange(1, 3650)]a numeric rangenumber of days
[ValidateSet('Archive', 'Delete')]a value from a list, with tab completion in the consoleoperating mode
[ValidatePattern('^srv-[a-z]+-\d{2}$')]a regular expressionserver name
[ValidateNotNullOrEmpty()]no empty valuelog directory

-WhatIf mode: SupportsShouldProcess

[CmdletBinding(SupportsShouldProcess)] adds the -WhatIf and -Confirm parameters to the script. Every change is wrapped in $PSCmdlet.ShouldProcess(target, operation), which in -WhatIf mode returns false and prints what would have been done:

What if: Performing the operation "Delete file" on target "D:\Export\report_2026-08-01.csv".

This lets a successor safely check what the script will do in production before allowing it to do it.

Errors: Stop, try, catch, finally

The default value of $ErrorActionPreference is Continue. Most errors in commands, for example Get-ChildItem on a path that does not exist, are then non-terminating: PowerShell prints a message and carries on with the next lines, and the catch block never runs. That is how the scenario from the start of this article happens. $ErrorActionPreference = 'Stop' turns every error into an exception that lands in catch.

Set-StrictMode -Version Latest adds a second safety catch: referencing a variable that does not exist is an error, so a typo in a name does not silently become an empty value. The finally block always runs, after an error too, so that is where closing the log, Stop-Transcript or releasing resources belongs.

The log: Start-Transcript or your own function

Start-Transcript -Path <file> -Append records everything that appears in the console, errors included. It is the simplest route for an existing script, but the log is verbose and hard to search. A function of your own, such as Write-Log in the skeleton, gives one line per event with a date and a level. Such a file is easy to search, to compare between days and to ship to a central log system.

Write-Verbose is kept for details: when run manually with -Verbose, every step is visible; when run by the scheduler, nothing clutters the output. Logs need cleaning up too, for example with a single line at the end of the script: Get-ChildItem $LogDirectory -Filter *.log | Where-Object LastWriteTime -lt (Get-Date).AddDays(-90) | Remove-Item -WhatIf:$false, which deletes logs older than 90 days.

Exit codes for the scheduler

Task Scheduler does not read the log. It sees only the code the powershell.exe process ended with, and shows it in hexadecimal in the “Last Run Result” column.

Code in the scriptMeaningResult in the scheduler
exit 0everything done0x0
exit 1critical error, work stopped0x1
exit 2partly done, details in the log0x2
none (set by the scheduler)the task has not run yet0x41303

The code reaches the scheduler when the script is started with -File. With -Command, the script’s exit code can be flattened to 0 or 1. Registering the task looks like this; the commands below create a task that will delete files every night at 2:00 from the next night onwards:

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -NonInteractive -File "D:\Scripts\Remove-OldExports.ps1" -Path "D:\Export" -OlderThanDays 30'
$trigger = New-ScheduledTaskTrigger -Daily -At '02:00'
$principal = New-ScheduledTaskPrincipal -UserId 'CORP\gmsa-scripts$' -LogonType Password
Register-ScheduledTask -TaskName 'Remove-OldExports' -TaskPath '\Scripts\' `
    -Action $action -Trigger $trigger -Principal $principal

-NonInteractive makes any prompt for a missing parameter or a confirmation end in an error, instead of leaving the task hanging forever.

No passwords in the script

Manual runs: Get-Credential asks for credentials at run time and passes them as an object to -Credential parameters.

Runs from the scheduler: a group managed service account (gMSA). The task from the previous section runs as gmsa-scripts$, which itself holds the permissions on the share or the directory objects. Nobody knows the password, and the domain changes it automatically, every 30 days by default. The account needs the “Log on as a batch job” right on the server, and the server must be listed in the account’s PrincipalsAllowedToRetrieveManagedPassword.

Tokens and keys for external APIs: the Microsoft.PowerShell.SecretManagement module with a vault extension, for example Microsoft.PowerShell.SecretStore or a cloud vault. Only a reference stays in the script: Get-Secret -Name 'ExportApiToken' -Vault 'Automation' -AsPlainText. A SecretStore vault belongs to a user, so it has to be configured in the context of the account that runs the task.

Quality: PSScriptAnalyzer and Pester

PSScriptAnalyzer is a static analyser that catches typical problems before the script reaches a server:

Install-Module PSScriptAnalyzer -Scope CurrentUser   # one-off module installation
Invoke-ScriptAnalyzer -Path .\Remove-OldExports.ps1 -Severity Warning, Error

Among other things, it detects aliases instead of full command names (PSAvoidUsingCmdletAliases), password parameters as plain text (PSAvoidUsingPlainTextForPassword) and functions that change state without supporting ShouldProcess (PSUseShouldProcessForStateChangingFunctions). Logic that calculates something, for example which files to delete, is worth extracting into a function and covering with Pester tests (Invoke-Pester). If the scripts live in a repository, the analysis and the tests run in CI on every change.

Signing and execution policy

Get-ExecutionPolicy -List shows the policy in every scope: MachinePolicy and UserPolicy come from Group Policy and take precedence over LocalMachine and CurrentUser. Execution policy is not a security boundary, because it can be bypassed with -ExecutionPolicy Bypass. Under AllSigned, however, the signature works as change control: any edit to the file on the server invalidates the signature and the script stops running, so fixes have to go through the repository and be signed again.

$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
Set-AuthenticodeSignature -FilePath .\Remove-OldExports.ps1 -Certificate $cert `
    -TimestampServer 'http://timestamp.digicert.com' -HashAlgorithm SHA256

The timestamp keeps the signature valid after the certificate expires. The publisher certificate must be in the Trusted Publishers store on the servers, for example distributed through Group Policy; otherwise, under AllSigned, PowerShell asks for consent, and with -NonInteractive it ends in an error.

Check your own environment

How the last run of tasks outside the Microsoft folder ended:

Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*' |
    Get-ScheduledTaskInfo |
    Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime

A LastTaskResult of 0 means success, 267011 (0x41303) a task that has not run yet, and 267009 (0x41301) a task running right now. Any other value is an error to investigate, and an old LastRunTime on a daily task means the scheduler is not starting it.

Which accounts the tasks run as:

Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*' |
    Select-Object TaskPath, TaskName,
        @{ n = 'Account'; e = { $_.Principal.UserId } },
        @{ n = 'Logon'; e = { $_.Principal.LogonType } }

A good result is SYSTEM or gMSA accounts, recognisable by the $ at the end. A personal account with the Password logon type means a password stored in the task, which will stop working when the password changes or the person leaves.

Whether scripts contain passwords written into the code:

Get-ChildItem D:\Scripts -Filter *.ps1 -Recurse |
    Select-String -Pattern 'ConvertTo-SecureString\s.*-AsPlainText', '\$\w*(password|passwd|pwd)\w*\s*=\s*[''"]' |
    Select-Object Path, LineNumber, Line

Empty output is good. Every hit is a secret to move to a gMSA or a secrets vault, and to change, because it has already been stored in plain text.

Whether the scripts are signed and which policy applies on the server:

Get-AuthenticodeSignature -FilePath (Get-ChildItem D:\Scripts -Filter *.ps1 -Recurse).FullName |
    Where-Object Status -ne 'Valid' | Select-Object Path, Status

Get-ExecutionPolicy -List

The first command should return nothing. HashMismatch means a file changed after signing, NotSigned a missing signature. In the second, the best case is MachinePolicy set to AllSigned or RemoteSigned through Group Policy; Unrestricted or Bypass in the LocalMachine scope is a signal to fix.