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.

  1. Which format you want
  2. 1. Create the file
  3. 2. Run it
  4. 3. Add a stage, and see the barrier
  5. 4. Two jobs at once, and tasks in order
  6. 5. Park the run on a human
  7. 6. Let AI draft it, and keep the decision yours
  8. Reference: workflows/*.yml
    1. Top-level keys
    2. A stage
    3. A job
    4. A task in a workflow
    5. params:
    6. Complete example
  9. Passing data between jobs
  10. A stage that runs nothing
  11. 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: ai is 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: NOTES stores what it drafted as a value. Not a decision — a value.
  • trigger: manual is on the task that acts, and it is there because you put it there. ai@1 enforces no gate of its own — it is an agent, and it can write to the repository. The publishing command is an ordinary run: 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.
  • name defaults 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

Back to top

Local Workflows is a VS Code extension. Everything it does is declared in a YAML file you own.


- 22-Aug-2026 08:02 PM +0000