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:
| Platform | File |
|---|---|
| Linux x64 | wf-linux-amd64 |
| Linux ARM64 | wf-linux-arm64 |
| macOS Intel | wf-macos-amd64 |
| macOS Apple Silicon | wf-macos-arm64 |
| Windows x64 | wf-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:
- An empty line, or a line starting with
#, is ignored. - A line starting with
[opens a section. The name is what is between[and]. Text after#on that line becomes the section description, shown bywf. Between]and#, only thelog=<file>option is allowed (see Run history). - Any other line is a command of the current section. Its first word is the command keyword, case-insensitive:
set,SETandSetare 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.
main), WF refuses to start with duplicated subcommand name.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:
| Variable | Value |
|---|---|
${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 write | The 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
| Command | Behavior |
|---|---|
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 cp | Copies 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 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 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.
| Field | Value |
|---|---|
| Date | When the run started. |
| Section | The section given on the command line, even if log is in a sub-workflow. |
| Result | SUCCESS, FAILED, CANCELLED (a confirm answered no) or EXIT <code>. |
| Duration | Of the whole run. |
| Who | user@machine. |
| Failed line | Only for FAILED: the line that failed, as written in the .wf file. |
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:
| Header | Result |
|---|---|
[deploy] log=deploy.log # Deploy | Logged to deploy.log |
[deploy] log=deploy.log | Logged, no description |
[deploy] log # Deploy | log needs a file (log=<file>) |
[deploy] logs=deploy.log | Unknown section option 'logs=deploy.log' |
[deploy] log=a.log log=b.log | log is set twice |
[deploy] Deploy the app | Unknown 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 code | When |
|---|---|
0 | The section ran to the end, or reached exit. |
1 | A 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. |
3 | The section given on the command line does not exist. |
| Any | The 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.