Documentation

Everything WF does, in the order it does it. Run every command from the folder that holds your .wf files: WF only reads the current directory.

1. Install

WF ships as 5 standalone binaries. Download the one for your platform from the latest release:

PlatformFile
Linux x64wf-linux-amd64
Linux ARM64wf-linux-arm64
macOS Intelwf-macos-amd64
macOS Apple Siliconwf-macos-arm64
Windows x64wf-windows-amd64.exe

Linux and macOS: make it executable and put it in your PATH.

$ chmod +x wf-linux-amd64
$ sudo mv wf-linux-amd64 /usr/local/bin/wf
$ wf --version          # prints: wf version 2.1.0
$ wf                    # prints the WF logo and your workflows

Windows: rename the file to wf.exe and place it in a folder listed in your PATH.

2. Quick start

Create a file ending in .wf at your project root, for example deploy.wf:

[build] # Build the application
echo "Building the application..."
run npm install
run npm run build

[deploy] # Build and start the containers
set ENV=production
wf build
docker_compose up -d
notify_success "Deployed in ${ENV}"

Then run:

$ wf                    # lists build and deploy, with their comments
$ wf deploy             # runs deploy, and build as a sub-workflow
$ wf build              # runs build on its own

3. Writing .wf files

WF reads every file ending in .wf in the current folder (not in sub-folders). Each line is trimmed, then:

  1. An empty line, or a line starting with #, is ignored.
  2. A line starting with [ opens a section. The name is what is between [ and ]. Text after # on that line becomes the section description, shown by wf. Between ] and #, only the log=<file> option is allowed (see Run history).
  3. Any other line is a command of the current section. Its first word is the command keyword, case-insensitive: set, SET and Set are the same.

Lines written before the first section form a section called main, run with wf main. A section with no command is dropped. wf lists the sections in the order they are written, file by file in alphabetical order.

Section names must be unique, inside each file and across all the .wf files of the folder. If the same name appears twice (including two files that both have lines before their first section, so both have a main), WF refuses to start with duplicated subcommand name.
Comments must be on their own line. run ls # list is not a comment: # and list are passed to ls.

WF does not check a file before running it. Each line is read when its turn comes; see Errors and exit codes.

4. Running wf

$ wf                    # logo, then the list of sections and their descriptions
$ wf <section>          # runs that section
$ wf help <section>     # shows the description of one section
$ wf --version          # prints the WF version

Section names are case-sensitive here: [Deploy] runs with wf Deploy, not wf deploy. An unknown name prints No help topic for '<name>' and exits with code 3. If the folder has no .wf file, WF also prints No workflow files found.

The section runs under a full-width WORKFLOW: <NAME> header and ends with Workflow '<name>' completed in <duration>.

5. Variables

Before running a line, WF replaces every ${NAME} it knows:

VariableValue
${CURRENT_PATH}The folder where wf was started.
${IP_LOCAL}The first non-loopback IPv4 address of the machine. Empty if there is none.
${GENERATE_SECRET}64 random hexadecimal characters (32 bytes). One new value per line: two uses on the same line get the same value.
${NAME}Any variable defined with set or loaded with load_env.
set APP=my-app
set IMAGE=registry.local/${APP}
echo "Building ${IMAGE} from ${CURRENT_PATH}"

set prints Variable NAME set to value. The name is what comes before the first =, the value is everything after it, including other = signs. Spaces around the name and the value are removed: set DSN = mysql://db?x=1 works. An empty name or value stops WF with Invalid set command.

load_env

load_env .env
echo "Connecting to ${DATABASE_URL}"

Reads the file and sets one WF variable per NAME=value line, like set. Empty lines, lines starting with # and lines without = are skipped. One pair of quotes around the value is removed ("..." or '...'). An empty value is allowed. ${...} inside the file is not replaced. WF prints how many variables were loaded, never their values. A missing file stops WF.

Variables are shared by every workflow of a run: a variable set in a sub-workflow is still there after it returns. An unknown ${NAME} is left in the line as is. set can also override CURRENT_PATH or IP_LOCAL.

WF variables live in WF only. They are not environment variables: a program started with run does not see them. It does see the environment WF itself was started with.

6. run and docker_compose

run composer install --no-dev
run php bin/console cache:clear
docker_compose up -d --build

run starts a program directly, without a shell. The line is split on spaces: the first word is the program, found in your PATH, the others are its arguments. Quotes work like in a terminal:

You writeThe program receives
git commit -m "fix bug"commit · -m · fix bug
--option="toto tata"--option=toto tata
-e 'SELECT 1'-e · SELECT 1
"it's" / 'say "hi"'it's / say "hi"
"${CURRENT_PATH}"The path as one argument, even with spaces

A quote that is never closed stops WF with Missing closing quote. A lone apostrophe opens a quote too: write "it's". Backslashes are plain text, so Windows paths like C:\Users\me work as written. Pipes (|), redirections (>), &&, globs (*), ~ and $VAR are passed as plain text.

Need shell features? Put them in a script and run the script:

run sh scripts/backup.sh
run cmd /c dir          # Windows built-in commands go through cmd

WF prints the command, frames its output, then shows its duration:

╭─ ▶ composer install --no-dev
│  Installing dependencies from lock file
│  ...
╰─ ✔ 3.4s

If the program cannot be found or exits with a non-zero code, WF prints ✘ Failed in <duration> and Command failed, then stops with exit code 1.

docker_compose <args> works like run, with docker compose <args> when the Compose plugin answers, or docker-compose <args> otherwise. The check is done once per run.

7. Files and folders

These commands are written in Go and do not depend on the system tools. Paths are relative to the folder where wf was started, and cannot contain spaces, except for touch.

remove dist
mkdir dist
write .env.local APP_SECRET=${GENERATE_SECRET}
move build/app dist/app
CommandBehavior
mkdir <dir>Creates the folder and its missing parents. Skipped if the path exists.
touch <file>Creates an empty file. Skipped if it exists (the date is not updated). The parent folder must exist.
write <file> <text>Appends <text> and a line break to the file, created if needed. One pair of quotes around the text is removed. The parent folder must exist. The text is not printed, so secrets stay out of your logs.
copy <src> <dst> or cpCopies one file to <dst>, the full path of the new file. Stops WF if <src> does not exist. Skipped if <dst> exists, file or folder: it never overwrites.
move <src> <dst>Moves or renames a file or folder. Stops WF if <src> does not exist. Skipped if <dst> exists: it never overwrites. Moving to another disk is not supported: use copy then remove.
remove <path>Deletes a file, or a folder and everything inside it. Skipped if the path does not exist. Refuses to delete the current folder or the root of a disk.
set_permissions <path> <mode>Applies an octal mode (0755, 644…). On a folder, the same mode is applied to the folder and to every file and folder inside it.
copy copies files, not folders. copy src/ dist/ fails, and copy app.env config/ is skipped when config/ exists. Write the full destination: copy app.env config/app.env.

On Windows, set_permissions can only toggle the read-only flag: a mode without the owner write bit (like 0444) makes files read-only, any other mode makes them writable.

8. Messages

echo "Plain message"
notify "Same as echo"
notify_info "Blue INFO label"
notify_success "Green SUCCESS label"
notify_warning "Yellow WARNING label"
notify_error "Red ERROR label"
sync_time                  # prints: System time: 2026-10-13 12:18:50 CEST

Messages are printed in the terminal; there is no desktop notification. Double quotes around the message are removed. notify_error only prints: the workflow continues. To stop, use exit or let a run fail.

sync_time prints the current system time and zone. It does not change the clock.

9. Checks and pauses

[deploy]
require docker git
confirm "Deploy to production?"
docker_compose up -d
sleep 5
docker_compose exec php bin/console doctrine:migrations:migrate -n

require <program...> checks that each program can be found in the PATH, and prints <program> found. A missing one stops WF with <program> is required but was not found in PATH. Put it at the top of a section, so nothing runs on a machine that is not ready.

sleep <seconds> waits a whole number of seconds, for example while a container starts.

confirm "<question>" prints the question followed by [y/N] and waits for an answer. y or yes, in any case, continues. Anything else, including just pressing Enter, stops WF with Cancelled and exit code 1.

confirm needs someone to answer. In CI, or when the input is piped, there is no answer: WF stops. Keep confirm in workflows that people run by hand.

10. Sub-workflows and exit

[deploy]
wf build
wf migrate
docker_compose up -d

wf <section> runs another section, then comes back to the next line. Sub-workflows can call other sub-workflows. Each level is indented in the output:

  ╭── 📦 Sub-Workflow: build
  ...
  ╰── ✔ Completed in 9.8s

Any section of any .wf file in the folder can be called. A name that does not exist stops WF with Workflow '<name>' not found. A section that calls itself, directly or through another one, never ends.

exit stops WF immediately, including every parent workflow, with exit code 0. exit <code> uses that code instead: exit 1 stops on purpose and marks the run as failed. A code that is not a number stops WF with Invalid exit code.

11. Run history

[deploy] log=deploy.log # Deploy the application
run composer install --no-dev
docker_compose up -d

The log=<file> option in a section header keeps a trace of every run of that section. When the run ends, WF appends one line to the file, created if needed:

2026-10-14 09:12:03 | deploy          | SUCCESS   | 12.4s   | fady@prod-01
2026-10-15 10:05:31 | deploy          | FAILED    | 3.1s    | fady@prod-01 | run composer install --no-dev
2026-10-16 18:30:12 | deploy          | CANCELLED | 2s      | marc@prod-01
2026-10-16 18:41:57 | build           | EXIT 3    | 900ms   | ci@runner-2

Columns are aligned for section names up to 15 characters; a longer name simply pushes the rest of its line.

FieldValue
DateWhen the run started.
SectionThe section given on the command line, even if log is in a sub-workflow.
ResultSUCCESS, FAILED, CANCELLED (a confirm answered no) or EXIT <code>.
DurationOf the whole run.
Whouser@machine.
Failed lineOnly for FAILED: the line that failed, as written in the .wf file.
No secret in the log. The failed line is logged before variables are replaced: run deploy --password=${DB_PASSWORD} stays as is. Variable values are never written.

Header rules

The option sits between ] and the comment, written exactly log=<file>. WF checks every header when it loads the .wf files, before anything runs, and refuses to start on any mistake:

HeaderResult
[deploy] log=deploy.log # DeployLogged to deploy.log
[deploy] log=deploy.logLogged, no description
[deploy] log # Deploylog needs a file (log=<file>)
[deploy] logs=deploy.logUnknown section option 'logs=deploy.log'
[deploy] log=a.log log=b.loglog is set twice
[deploy] Deploy the appUnknown section option 'Deploy': a description needs #

The log belongs to the section you launch, and covers the whole run, sub-workflows included. A log= on a section called with wf <section> is not used; it only applies when that section is launched directly. Without log=, no file is written. A run stopped with Ctrl+C is not logged. Decide yourself whether the log file goes in Git or in .gitignore.

12. Errors and exit codes

WF stops at the first error. Lines already run stay done: there is no rollback.

Exit codeWhen
0The section ran to the end, or reached exit.
1A run or docker_compose failed, a file command failed, a require was not met, a confirm was not answered y, a wf section was not found, a line had an unknown command or invalid syntax, or two sections share a name.
3The section given on the command line does not exist.
AnyThe code given to exit <code>.

An unknown command keyword prints Unknown command or invalid syntax : <line>. Since lines are checked when they run, a typo at the end of a section only shows up after the lines above it have run.

13. Git and CI

Commit your .wf files: they are your project's runbook. WF writes nothing besides what your commands create.

In CI or on a server, download the binary and run a section:

$ curl -fsSL -o /usr/local/bin/wf https://github.com/michelware/wf/releases/latest/download/wf-linux-amd64
$ chmod +x /usr/local/bin/wf
$ wf deploy

The exit code of wf tells your CI whether the workflow succeeded.