📜 Runbook format
A runbook is a markdown file. Frontmatter at the top sets it up, each ## heading is a step, and code blocks marked for paulrun are shown, paused on, or run. Everything else is documentation.
Run paulrun check on a runbook to find anything wrong with it.
Frontmatter
YAML between --- lines at the top of the file:
---
title: Release my-package # required
description: Release a new version. # optional
output_path: logs/release-<VERSION>.md # optional
python: uv run python # optional
step: true # optional, default false
inputs: # optional
- name: VERSION # required
description: The version to release, e.g. 1.2.0 # required
pattern: '^\d+\.\d+\.\d+$' # optional
- name: PUBLISH_TOKEN
description: Token for the package index
secret: true # optional, default false
---
| Key | What it does |
|---|---|
title |
The runbook's name, shown when it runs. Required. |
description |
What it's for, shown by paulrun library. |
output_path |
Where to keep a transcript of each run. |
python |
The command that runs Python blocks. |
step |
true to ask before each run block, as --step does, without having to pass it. --no-step turns it off for one run. |
inputs |
Values to ask for. See Inputs. |
Any other key is a check warning, so a typo like ouput_path gets noticed.
Inputs
Values a runbook needs each time it runs, such as the version being released.
| Key | What it does |
|---|---|
name |
Required, in UPPER_SNAKE_CASE, e.g. VERSION. |
description |
Required. Shown when paulrun asks for it. |
pattern |
A regular expression the whole value must match, e.g. '^\d+\.\d+\.\d+$'. |
secret |
true for passwords and tokens. See Secrets. |
default |
Used if you press enter. See Defaults. |
choices |
The only values accepted. See Choices. |
paulrun collects every input before the first step:
- If an environment variable with the input's name is set, it's used:
VERSION=1.2.0 paulrun RELEASING.mddoesn't ask. An empty variable counts as not set. - Otherwise paulrun asks for it.
A typed answer that doesn't fit the input's pattern or choices is asked for again. A value from the environment that doesn't fit stops paulrun before anything runs.
Using inputs
Every input is an environment variable in every run block: $VERSION in shell, os.environ["VERSION"] in Python.
An input that isn't secret can also be written as a placeholder, <VERSION>, in run, docstring and confirm blocks and in output_path. paulrun fills it in. The rest of the runbook is never changed.
A placeholder that isn't a declared input is a check error, and an input nothing uses, as <VERSION> or $VERSION, is a warning.
Placeholders are filled in as plain text
If someone enters 1.2.0; rm -rf ~ for <VERSION>, that's what the block runs. Give inputs used in commands a pattern.
Defaults
inputs:
- name: BRANCH
description: The branch to release from
default: main
BRANCH (The branch to release from) [main]:
Pressing enter takes main. The environment still comes first, and a default must still match the input's pattern.
Quote a default YAML would read as something other than text, such as default: '1.10', which would otherwise be the number 1.1. A secret can't have a default.
Choices
inputs:
- name: FE_SET
description: Which frontend set to rebuild
choices: [comms, billing, all]
default: comms
FE_SET (Which frontend set to rebuild) [comms] (comms, billing, all): everything
FE_SET must be one of: comms, billing, all
Type the choice itself, not its number. Choices are compared exactly, so Comms isn't comms. A default must be one of the choices, every choice must match the pattern if there is one, and a secret can't have choices.
Secrets
An input with secret: true is never shown or saved:
- Its prompt hides what's typed.
- It's never a placeholder. Read it as
$PUBLISH_TOKEN; writing<PUBLISH_TOKEN>is acheckerror. - Its value is shown as
****in block output and the transcript. paulrun --drydoesn't ask for it.
uv publish --token "$PUBLISH_TOKEN"
Note
paulrun spots a secret by its exact value, one line at a time. A command that encodes it, or splits it across lines, can still reveal it. Pass secrets to commands rather than printing them.
Input blocks
Some values only exist part way through a run, like the number GitHub gives a pull request you've just opened. An input block asks for one when the run reaches it:
## Open the pull request
```confirm
Open a pull request for <VERSION> on GitHub.
```
```input
name: PR
description: The number GitHub gave the pull request
pattern: '^\d+$'
```
```sh run
gh pr merge <PR>
```
It takes the same keys as a frontmatter input, and works the same from then on.
- Using it before the block, or in
output_path, is acheckerror. paulrun --drydoesn't ask for it, and shows<PR>as written.
A one-time code is a good use of a secret input block:
```input
name: OTP
description: The code from your authenticator app
pattern: '^\d{6}$'
secret: true
```
```sh run
npm publish --otp "$OTP"
```
Steps
Each ## heading starts a step, named by its text. ### and smaller headings are just text inside the step.
A block before the first ## heading is a check error.
Blocks
The first word of a code block's opening line is its language. A second word of run makes paulrun run it.
| Block | What paulrun does |
|---|---|
```docstring |
Shows the text. |
```confirm |
Shows the text and waits for y. Any other answer ends the run. |
```input |
Asks for an input. See Input blocks. |
```sh run |
Runs it. See Run blocks. |
Anything else, e.g. ```sh |
Nothing. It's documentation. |
So a plain ```sh block shows a command without paulrun running it.
```docstring
Releasing <VERSION>. This takes a few minutes.
```
```confirm
Create a GitHub release from tag <VERSION>, then come back here.
```
Run blocks
paulrun shows each block's output as it's printed. Exit code 0 carries on; anything else stops the run.
Every block runs with paulrun's environment and every input, plus:
PAULRUN_RUNBOOK_DIR, the folder the runbook is inPYTHONUNBUFFERED=1, so Python output isn't held backPAGER=catandGIT_PAGER=cat, so nothing waits in a pager
Run paulrun backends to see which languages you can use, or add your own with backends.
Where blocks run
Blocks run in the folder you run paulrun from, not the runbook's folder. So one runbook can work on whichever project you're in:
cd ~/Projects/website
paulrun ~/runbooks/update-deps.md
Use $PAULRUN_RUNBOOK_DIR for files kept next to the runbook:
cp "$PAULRUN_RUNBOOK_DIR/templates/pull-request.md" .github/
Each block is its own process, so a cd or export is gone by the next block. Keep commands that need them together.
Shell blocks
sh, bash or shell. Each block is one bash script that stops at the first failing command, as if it began with set -eo pipefail. check finds syntax errors, and shellcheck warnings if it's installed.
Python blocks
python or py. Each block is its own script, run with the Python paulrun runs on, so only the standard library is available. To use another interpreter or your project's dependencies, set python:
python: uv run python
check compiles each block with paulrun's own Python, so syntax only a newer Python understands isn't caught.
Blocks that ask questions
A block can still ask on the terminal, the way git and ssh ask for a password. But output is shown a line at a time, so a question with no newline after it isn't shown until you answer. Use inputs for values instead.
Transcript
Set output_path, and each run is added to the end of that file, so a failed attempt and the rerun that fixed it are both kept:
output_path: logs/release-<VERSION>.md
A relative path is relative to the folder you run paulrun from. Missing folders are created, and --dry runs aren't recorded.
--- paulrun RELEASING.md @ 2026-10-12T15:11:27+01:00
--- inputs: VERSION=1.2.0 PUBLISH_TOKEN=****
=== Run the tests ===
make test
<everything make test printed>
exit 0 (4.2s)
=== Publish on GitHub ===
confirm: Create a GitHub release from tag 1.2.0. -> yes
=== Publish to PyPI ===
input: OTP=****
uv publish --token "$PUBLISH_TOKEN"
skipped
--- finished: ok (3m12s)
The last line says how the run ended:
| Last line | When |
|---|---|
--- finished: ok (3m12s) |
Every step ran. |
--- finished: failed at "<step>" (exit N) |
A block exited with N. |
--- finished: aborted at "<step>" |
A confirm wasn't answered y, or you quit with --step. |
--- finished: interrupted at "<step>" |
You pressed Ctrl-C. |
Known limits
- bash on macOS is version 3.2, which can't spot an unclosed heredoc (
cat <<EOFwith noEOF). - Windows isn't supported.