The Scripts Every Project Has

A build.ps1 gathers a project's chores in one script, each task a function that -Task picks through a ValidateSet parameter and a switch; Get-Command checks that the tools the project needs are installed, and $ErrorActionPreference = 'Stop' with throw stops the script at the first problem.

Journeyman · PowerShell · Updated

After this lesson you can

  • Write a build.ps1 that runs one task chosen with -Task, using a ValidateSet parameter and a switch
  • Check that a tool is installed with Get-Command, and stop a script at the first problem with $ErrorActionPreference and throw

Key takeaways

  • A build script writes a project's chores down once: .\build.ps1 -Task build runs one of them, the same way for everyone.
  • Each task is a function. A ValidateSet parameter lists the task names, and a switch at the bottom runs the one you chose.
  • Get-Command -ErrorAction SilentlyContinue gives back nothing when a tool isn't installed, so an if can stop with a clear message.
  • throw stops the script with your own message, and $ErrorActionPreference = 'Stop' makes every other error stop it too.

You download a website project from GitHub, or join a team that has one. How do you build it? Someone tells you: "copy src into dist, but delete the old dist first, and make sure you have Git." Next month a new teammate asks the same question, and gets a slightly different answer. Every software project has the same chores: check that the tools are there, build, test, package, clean up. A build script writes them down once, as a script, so everyone runs them the same way with one command, and a build server can run that same command on every change. Web developers, .NET developers and the people who keep a team's builds working write and run these scripts every day.

A website to practice on

This chapter's project is the website of Maple Street Bikes, a small bike shop: two pages and a style sheet in a src folder (short for source, the files you edit). Run these lines to make it. They print nothing, and they're safe to run again:

New-Item -ItemType Directory -Force -Path "$HOME\terminal-practice\bike-shop-site\src" | Out-Null
Set-Content -Path "$HOME\terminal-practice\bike-shop-site\src\index.html" -Value '<!DOCTYPE html>', '<html lang="en">', '<head><title>Maple Street Bikes</title><link rel="stylesheet" href="style.css"></head>', '<body><h1>Maple Street Bikes</h1><p>Bikes, repairs and rentals since 1998.</p><a href="repairs.html">Repairs</a></body>', '</html>'
Set-Content -Path "$HOME\terminal-practice\bike-shop-site\src\repairs.html" -Value '<!DOCTYPE html>', '<html lang="en">', '<head><title>Repairs: Maple Street Bikes</title><link rel="stylesheet" href="style.css"></head>', '<body><h1>Repairs</h1><p>A flat tire fixed while you wait: $12.</p><a href="index.html">Home</a></body>', '</html>'
Set-Content -Path "$HOME\terminal-practice\bike-shop-site\src\style.css" -Value 'body { font-family: sans-serif; margin: 2rem; }', 'h1 { color: darkgreen; }'
Set-Content -Path "$HOME\terminal-practice\bike-shop-site\README.md" -Value '# Maple Street Bikes website', '', 'Build the site with: .\build.ps1 -Task build'
Set-Location "$HOME\terminal-practice\bike-shop-site"

The README already promises a build.ps1. Time to write it.

One script, many tasks

Think of a coffee machine with a few buttons: one machine, and you press the drink you want. build.ps1 works the same way. Each job, called a task, is a function, and one parameter, -Task, is the button. Save this as build.ps1 in the bike-shop-site folder, with code build.ps1 or edit build.ps1:

# build.ps1: the jobs for the Maple Street Bikes website.
# Run one task: .\build.ps1 -Task build
param(
    [ValidateSet('build', 'clean')]
    [string] $Task = 'build'
)

$ErrorActionPreference = 'Stop'
$src = Join-Path $PSScriptRoot 'src'
$dist = Join-Path $PSScriptRoot 'dist'

function Invoke-BuildTask {
    if (Test-Path $dist) { Remove-Item $dist -Recurse }
    Copy-Item -Path $src -Destination $dist -Recurse
    Write-Output "Built $((Get-ChildItem $dist -File).Count) files into dist"
}

function Invoke-CleanTask {
    if (Test-Path $dist) { Remove-Item $dist -Recurse }
    Write-Output 'Removed dist'
}

switch ($Task) {
    'build' { Invoke-BuildTask }
    'clean' { Invoke-CleanTask }
}

Read it from the bottom up. The switch (Many Choices: switch) runs the one function the task names. [ValidateSet('build', 'clean')] (Checking Input: Validation Attributes) is the list of buttons: PowerShell refuses any other name, and Tab offers these two after -Task. = 'build' makes building what happens when you don't say. The build deletes the old dist (short for distribution, the finished site) and copies src into a fresh one. Every path starts at $PSScriptRoot, the script's own folder, so the script works from any folder (Run It from Anywhere). Run it:

.\build.ps1 -Task build
Get-ChildItem dist -Name
Built 3 files into dist
index.html
repairs.html
style.css

A name that isn't on the list stops before anything runs:

.\build.ps1 -Task deploy
build.ps1: Cannot validate argument on parameter 'Task'. The argument "deploy" does not belong to the set "build,clean" specified by the ValidateSet attribute. Supply an argument that is in the set and then try the command again.

Most projects use the same few names: build, test, package, publish and clean. A new task is a new function, a new name in the ValidateSet and a new line in the switch.

Is the tool installed? `Get-Command`

The site's history will live in Git, so this project needs git, and pwsh, PowerShell 7. A teammate without them should hear so at once, not halfway through a build. Get-Command (Finding a Command: Get-Command) finds programs as well as cmdlets:

Get-Command git
CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Application     git.exe                                            2.51.1.1   C:\Program Files\Git\cmd\git.exe

Application means a program, and Source is where it lives. (Your version may differ.) For a tool that isn't installed, Get-Command writes an error. With -ErrorAction SilentlyContinue it gives back nothing at all instead, so if (-not (Get-Command ...)) reads "if this tool is missing". Add a check task: put this function above Invoke-BuildTask, add 'check' to the ValidateSet list, and add 'check' { Invoke-CheckTask } to the switch:

function Invoke-CheckTask {
    foreach ($tool in 'git', 'pwsh') {
        if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
            throw "Install $tool first: this project needs it."
        }
        Write-Output "Found $tool"
    }
}

throw (read "throw") stops the script with an error that says your message. Run the check:

.\build.ps1 -Task check
Found git
Found pwsh

On a computer without Git, the same check stops at once and says what to do:

Exception: C:\Users\Sam\terminal-practice\bike-shop-site\build.ps1:15
Line |
  15 |              throw "Install $tool first: this project needs it."
     |              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Install git first: this project needs it.

A build script drawn as a machine with three labeled buttons, check, build and clean: the -Task parameter presses one, and only the function behind that button runs

Stop at the first problem

One line near the top hasn't been explained yet: $ErrorActionPreference = 'Stop'. $ErrorActionPreference is a preference variable: it sets -ErrorAction (Reading Errors, and -ErrorAction) for every command in the script at once, and Stop turns every error into a full stop. To see why a build wants that, put a # in front of the line, save, hide the src folder for a moment and build:

Rename-Item src src-away
.\build.ps1 -Task build
Copy-Item: C:\Users\Sam\terminal-practice\bike-shop-site\build.ps1:23
Line |
  23 |      Copy-Item -Path $src -Destination $dist -Recurse
     |      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Cannot find path
     | 'C:\Users\Sam\terminal-practice\bike-shop-site\src' because it does not exist.

Get-ChildItem: C:\Users\Sam\terminal-practice\bike-shop-site\build.ps1:24
Line |
  24 |      Write-Output "Built $((Get-ChildItem $dist -File).Count) files in …
     |                             ~~~~~~~~~~~~~~~~~~~~~~~~~
     | Cannot find path
     | 'C:\Users\Sam\terminal-practice\bike-shop-site\dist' because it does not exist.

Built 0 files into dist

Two errors, and then a cheerful last line. A build server reading that last line, or a tired person, would carry on and publish an empty site. Take the # out again, save, and run it once more: now the first error, on line 23, is the last thing the script does. Then put the folder back with Rename-Item src-away src.

Everything here works the same in Windows PowerShell 5.1, and on a Mac or Linux, where you run ./build.ps1.

Check yourself

2 questions to try on the lesson page.

So what

A project's chores, written down once in build.ps1, run the same way for you, for the next person and for a build server, and stop the moment something is wrong.

Key terms

Cheat sheets

Related