Workflows
One pipeline, run as a whole, with an order that is part of what it
means. This page is the whole feature — a five-minute build-up first,
then every key and rule. It assumes you have read
Tasks; everything you learned
there about run:, env:, vars:, cwd: and templating means the
same thing here, because both formats run through the same task
executor.
- Which format you want
- 1. Create the file
- 2. Run it
- 3. Add a stage, and see the barrier
- 4. Two jobs at once, and tasks in order
- 5. Park the run on a human
- 6. Let AI draft it, and keep the decision yours
- Reference:
workflows/*.yml - Passing data between jobs
- A stage that runs nothing
- Errors and silent failures
Which format you want
tasks.yml |
workflows/*.yml |
|
|---|---|---|
| Path | .local-workflows/tasks.yml |
.local-workflows/workflows/*.yml |
| Is | a library of commands | one pipeline |
| Tasks are | a map, keyed by id | a list, in order |
| Ordering | needs: between tasks |
stages:, then needs: between jobs |
needs: on a task |
allowed | refused — hard error |
| Runs | any task, alone (plus its needs: chain) |
the whole file |
version: |
optional, read as 1 |
required, must be 1 |
name: |
ignored | the row’s label |
Reach for a workflow when the order is the point — a release that
drafts notes, waits for a human, then publishes. Reach for tasks.yml
when you have a pile of commands people run individually.
1. Create the file
Workflows live one folder deeper:
.local-workflows/workflows/. Create release.yml in it:
version: 1
name: Release
desc: Builds, then publishes.
stages:
- name: verify
jobs:
build:
name: Build
tasks:
- name: Compile
run: echo Compiling...
Three levels, and all three are mandatory: stages hold jobs, jobs hold tasks. Even for one command.
version: cannot be omitted because this format has no legacy to forgive.
tasks.yml forgives an absent version only because files written before
versions existed have none.
2. Run it
The file appears in the Local Workflows view beside your tasks. Click it to open — same rule as before, clicking never runs anything — then hit ▶.
There is no way to run one job. A workflow is addressed as a whole;
that is the difference from a tasks.yml, where every task has its own ▶.
If you want the parts individually runnable, they belong in a tasks.yml.
3. Add a stage, and see the barrier
Stages are sequential, with a hard barrier between them. Nothing in stage two starts until everything in stage one has finished:
stages:
- name: verify
jobs:
build:
name: Build
tasks:
- name: Compile
run: echo Compiling...
- name: release
jobs:
publish:
name: Publish
tasks:
- name: Publish the build
run: echo Publishing...
Run it. The panel draws the two stages in order, and publish sits
waiting until build is green.
4. Two jobs at once, and tasks in order
Inside a stage, jobs with no needs: between them run at the same
time — because the file says they do not depend on each other:
- name: verify
desc: Two checks that do not depend on each other.
jobs:
build:
name: Build
tasks:
- name: Compile
run: echo Compiling...
- name: Archive
run: echo Archiving...
lint:
name: Lint
tasks:
- name: Analyse sources
run: echo Linting...
build and lint start together. Inside build, Compile and
Archive do not: a job’s tasks are a list, and a list already carries
its order.
needs: on a task is a hard error, not a no-op. A list and a needs:
key disagreeing about order is worse than either one alone, so only jobs
take needs: — one id, or a list of them, naming jobs in the same
stage. Ordering across stages is what stages are for.
5. Park the run on a human
trigger: manual stops the run and waits for a person:
publish:
name: Publish
tasks:
- name: Publish the build
trigger: manual
run: echo Publishing to ${{ vars.target }}...
Add vars: { target: production } at the top of the file and run it. The
run parks, and the panel offers Approve and Reject.
Two things worth knowing about that pause:
- What you are shown is the resolved command — already
echo Publishing to production..., not the template. Approving a template would be approving a promise rather than a command. - Nothing is executing while it waits. No process, no timer. Close VS Code, reopen it, and run Local Workflows: Show Runs Waiting for Approval — the run is still there.
Reject it and the tasks after the gate are reported as Skipped, not left looking like they are still to come.
6. Let AI draft it, and keep the decision yours
This is the shape the whole engine exists for: a model proposes, a human approves, a task executes.
This step needs a GitHub Copilot sign-in. Everything above works without one — if you have not got Copilot, you already have a working workflow and can stop here.
Declare what runs the AI once, at the top of the file, then the whole file:
version: 1
name: Release
desc: Builds, drafts the notes, then waits for a person before publishing.
vars:
target: production
plugins:
ai:
uses: ai@1
args:
provider: ghcp
stages:
- name: verify
desc: Two checks that do not depend on each other.
jobs:
build:
name: Build
tasks:
- name: Compile
run: echo Compiling...
- name: Archive
run: echo Archiving...
lint:
name: Lint
tasks:
- name: Analyse sources
run: echo Linting...
- name: release
desc: AI drafts, a human approves, a task publishes.
jobs:
notes:
name: Release notes
tasks:
- name: Draft the notes
uses: ai
args:
prompt: Draft release notes from the commits on this branch.
output: NOTES
publish:
name: Publish
needs: notes
tasks:
- name: Approve the release
trigger: manual
run: echo Publishing to ${{ vars.target }} - "${{ run.context.NOTES.summary }}"
- name: Announce
run: echo Announced
Run it, and read the gate. It shows you the sentence the model actually wrote, because the run variable resolved before anybody was asked.
Three lines in there are the entire argument:
uses: aiis a name you declared. Swapping the vendor is one word at the top of the file; no task mentions a vendor or a model.output: NOTESstores what it drafted as a value. Not a decision — a value.trigger: manualis on the task that acts, and it is there because you put it there.ai@1enforces no gate of its own — it is an agent, and it can write to the repository. The publishing command is an ordinaryrun:you wrote and someone reviewed; all the model did was fill in${{ run.context.NOTES.summary }}.
The model drafts; a command you wrote publishes. What the model cannot
do is decide that the release goes out — that is the gate, and the gate
is a line in your file. It is a split of authority, not of capability.
If a session must not touch anything, say so in excludedTools: — see
ai@1.
Samples/HelloWorld/.local-workflows/workflows/release-pipeline.yml
exercises every shape the format can express, each case numbered and
commented. approve-a-deploy.yml beside it is the gate on its own, and
ado-breakdown.yml is a real one end to end.
Reference: workflows/*.yml
Structure: stages: (ordered, each a barrier) → jobs: (map; a DAG via
needs:; jobs without needs: between them run in parallel) → tasks:
(a list; always sequential, list order is the order).
Top-level keys
| Key | Type | Default | Meaning |
|---|---|---|---|
version |
1 |
required | Missing or wrong is a parse error. |
name |
string |
Unnamed Workflow |
The row’s label in the tree. |
desc |
string |
One line. | |
label |
string |
falls back to name |
What a run is called, resolved once at start. May read anchors, params: and file-level vars: — not env:, not run.context.*. |
template |
string |
default |
inline = the tasks.yml shape; anything else parses as the stages format. Rarely written. |
params |
map | See params:. |
|
vars / env / dotenv / plugins |
Same as tasks.yml — see Values and templating. |
||
stages |
list | The stages, in order. |
A stage
| Key | Type | Meaning |
|---|---|---|
name |
string |
Defaults to position, e.g. stage-2. |
desc |
string |
One line. |
if |
condition | Runs the stage only when true. |
artifact |
string |
A path this stage produces, resolved at run time (may read params). A stage whose artifact already exists on disk is satisfied: its jobs never run. An unresolvable artifact never counts as satisfied. |
jobs |
map | Jobs keyed by id; the id is what a sibling’s needs: names. |
A job
| Key | Type | Meaning |
|---|---|---|
name |
string |
Defaults to the job’s id. |
desc |
string |
One line. |
needs |
string | string[] |
Job ids in this same stage that must finish first. |
cwd |
string |
Relative to the file’s starting directory; its tasks’ cwd: resolve from here. |
vars / env / dotenv |
Merged over the file’s. | |
tasks |
list | Always sequential. |
A task in a workflow
Identical to a tasks.yml task
with two differences:
needs:on a task is refused — a hard error. Ordering inside a job is the list order;needs:belongs on the job.namedefaults to position (task-2), not to a map key.
params:
Values the run is asked for before it starts. Workflow files only.
Param names must match [A-Za-z_][A-Za-z0-9_]* — a param called
work-item could never be read back as an expression, so it is rejected
at the declaration. Params resolve before vars:, so vars: may read
${{ params.x }}.
| Key | Type | Meaning |
|---|---|---|
desc |
string |
Shown in the prompt. |
required |
boolean |
Default true — a param exists because the file cannot supply the value; opting out is what you declare. required: true alongside default: is an error. |
default |
string | number | boolean |
Makes the param optional. Must be one of options: when declared. |
options |
list | The only accepted values, offered as a pick list. Read as text. |
An empty declaration (ticket: with nothing under it) means required, no
description.
A run only asks for what it has no answer for. Answer once and later
runs go straight through. The run panel’s Params tab is where an
answer lives afterwards: every declared param with its current answer, a
menu where options: closes the set, a text box otherwise. Clear a box
and the param is un-answered, so the next run asks again — the only
route back out of an answer. The tab never prompts; it only edits, and
is read-only while the run is going.
Complete example
version: 1
name: Release
label: "Release ${{ params.tag }}"
params:
tag:
desc: Version tag for this release
plugins:
ai:
uses: ai@1
args:
provider: ghcp
stages:
- name: verify
jobs:
build:
tasks:
- name: Compile
run: npm run build
lint: # no needs: - runs alongside build
tasks:
- name: Lint
run: npm run lint
- name: publish
jobs:
release:
tasks:
- name: Draft notes
uses: ai
args:
prompt: Draft release notes for ${{ params.tag }}.
output: NOTES
- name: Publish
uses: pwsh@1
trigger: manual # a human reads the draft first
args:
script: ./publish.ps1 -Tag "${{ params.tag }}" -Notes "${{ run.context.NOTES.summary }}"
Passing data between jobs
There is no job-level outputs: block, deliberately. A task names a run
variable with output:, and anything later reads it as
${{ run.context.NAME.key }} — run variables are
run-scoped, so they cross job and stage boundaries without a second
mechanism.
The run panel’s Data tab lists them as they are produced — the
expression that reads each one, the task that set it, and the value. A
string renders as markdown; anything else is shown as JSON. The same tab
shows what the run was given: params:, the file’s vars: and declared
env:, and the directory anchors. Select a task and it also shows the
args: that task was actually handed — resolved, not the templates you
wrote.
The Old Runs tab lists the last 15 runs of this file, newest first. Click a row and that run opens in the same tree, read back off its own record. Clear history deletes the finished runs of this file and everything under them — the editor asks first, there is no undo, and a run still going or parked at a gate is kept: a waiting run holds nothing in memory, so those rows are the only thing left to resume it from.
A stage that runs nothing
A task declaring neither run: nor uses: is legal. It succeeds, logs
Task has nothing to run, and the walk carries on:
- name: sign-off
jobs:
manual-qa:
tasks:
- name: QA runs the release checklist by hand
This is how you say the work for this phase happens somewhere the engine cannot reach — a deploy approved out of band, a manual QA pass. A stage needs a job and a job needs a task, so a phase that genuinely runs nothing still has to say so.
It is safe to offer because an empty task is only ever what the author
actually wrote. A misspelled key is still a hard error: use: pwsh@1
fails with unknown key ‘use’ rather than quietly becoming a task that
does nothing and reports success.
Errors and silent failures
Everything on the tasks.yml list applies here too. On top of it:
version: missing or not 1 |
parse error naming what to write |
needs: on a task in a job |
hard error |
| Unknown stage/job/param key | hard error — both key sets are closed |
A label: that fails to resolve |
falls back to name:; the validator flags it, the run still starts |