Writing a new workflow
From an empty file to a release pipeline with a human gate.
- 1. The smallest workflow that runs
- 2. Add a second job that runs alongside
- 3. Add a stage
- 4. Tasks are a list, and
needs:is not allowed on them - 5. Ask the caller for a value
- 6. Stop for a human
- 7. Pass data between jobs
- 8. Declare a plugin once
- 9. A stage that runs nothing
- Where to put it
1. The smallest workflow that runs
Create .local-workflows/workflows/release.yml:
version: 1
name: Release
stages:
- name: build
jobs:
compile:
tasks:
- echo Compiling...
version: 1 is mandatory here — unlike in a tasks.yml, leaving it out
is a parse error naming exactly what to write. name: is the row’s label
in the sidebar.
The file appears in the tree. Clicking opens it; the ▶ runs the whole pipeline, because a workflow is one pipeline rather than a menu.
2. Add a second job that runs alongside
version: 1
name: Release
stages:
- name: verify
jobs:
build:
tasks:
- name: Compile
shell: pwsh
run: ./build.ps1
lint: # no needs:, so it runs beside build
tasks:
- name: Lint
shell: pwsh
run: ./lint.ps1
Two jobs in one stage with no needs: between them run at the same
time. If lint must wait for build, say so:
lint:
needs: build # a string, or a list of job ids
needs: names jobs in the same stage. Ordering across stages is what
stages are for.
3. Add a stage
Stages are a barrier. Nothing in publish starts until every job in
verify has finished:
stages:
- name: verify
jobs:
build:
tasks:
- name: Compile
run: ./build.ps1
- name: publish
jobs:
release:
tasks:
- name: Publish
run: ./publish.ps1
4. Tasks are a list, and needs: is not allowed on them
Inside a job, tasks run top to bottom. That is the ordering.
release:
tasks:
- name: Draft the notes
run: ./notes.ps1
- name: Publish
run: ./publish.ps1 # runs after, because it is written after
Writing needs: on a task is a hard error naming exactly that problem.
A list already carries the order; a second mechanism that could disagree
with it would be worse than either alone.
Otherwise a task here is the same task you write in a tasks.yml — bare
strings work, run: works, uses: + args: works, and so do shell:,
cwd:, env:, if:, timeout:, retries: and continueOnError:.
5. Ask the caller for a value
params: are collected before the run starts:
version: 1
name: Release
params:
tag: # bare name = required, no description
environment:
desc: Where this goes
options: [staging, production]
default: staging
stages:
- name: publish
jobs:
release:
tasks:
- name: Publish
run: ./publish.ps1 -Tag "${{ params.tag }}" -Env "${{ params.environment }}"
A param accepts exactly four keys — desc, required, default,
options — and the set is closed, because a misspelled requred: true
that became an unknown property would turn a mandatory value into an
optional one without saying so.
Rules worth knowing up front:
- Required is the default. A param exists because the file cannot supply the value, so “you must give me this” needs no ceremony; opting out is what you declare.
- A
default:andrequired: truetogether is an error. A param with a default is never missing, sorequired:has nothing left to decide. - A default outside its own
options:is an error — the file contradicting itself with a value the prompt could never produce. - Names must be
[A-Za-z_][A-Za-z0-9_]*. A param calledwork-itemcould be declared and then never referenced, because${{ params.work-item }}does not parse as an expression. It is rejected at the declaration instead.
6. Stop for a human
- name: Publish
trigger: manual
run: ./publish.ps1
The run parks there until somebody approves it, and the parked state is persisted — it survives closing the editor.
7. Pass data between jobs
There is no job-level outputs: block. A task names a run variable with
output:, and anything later reads it:
- name: publish
jobs:
release:
tasks:
- name: Draft the notes
uses: ai
args:
prompt: Draft release notes from the commits on this branch.
output: NOTES # names the run variable
- name: Publish
trigger: manual
if: ${{ vars.target }} == production
timeout: 10m
run: ./publish.ps1 -Title "${{ run.NOTES.title }}"
Run variables are already run-scoped, which is why they cross job and stage boundaries without a second mechanism.
8. Declare a plugin once
plugins: gives a uses: reference plus default args a local name:
plugins:
ai:
uses: ai@1
args:
provider: ghcp # the vendor, named once for the whole file
notify:
uses: pwsh@1
args:
script: ./notify.ps1
Any task can then say uses: ai or uses: notify, and its own args:
win over the defaults.
An entry name must not contain @ — a bare name is what tells an alias
apart from a real id@version where it is used, and an entry may not
point at another entry.
An entry accepts only uses: and args:. Everything the plugin
consumes goes under args:.
Two entries may name the same plugin with different defaults — which is
how one file runs ai@1 on two providers, or two models, without a
second plugin existing.
9. 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.
Where to put it
.local-workflows/workflows/*.yml in the repository, or
~/.local-workflows/workflows/*.yml in your profile to have it available
in every workspace you open. With a .code-workspace open there is a
third place — beside that file, shared by every repository it lists.
A file from a narrower scope hides a wider one of the same name::
folder beats workspace beats profile. See
Workspaces.
For every key in detail, run Local Workflows: Schema Reference from the Command Palette.