Your first local workflow

From one pipeline file to a release that builds, drafts its own notes, and stops for a person before it publishes. About five minutes.

This page assumes you have finished your first tasks.yml.

  1. Why a second file at all
  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. What you now have

Why a second file at all

A tasks.yml is a library of commands — you run one, and it pulls in what it needs. A workflow is one pipeline, run as a whole, with an order that is part of what it means.

Reach for a workflow when the order is the point: build, then draft the notes, then wait for a human, then publish. Reach for tasks.yml when you have a pile of commands people run individually.

Both run through the same task executor, so everything you learned about run:, env:, vars:, cwd: and templating means the same thing here.


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.

Two keys behave differently to tasks.yml:

   
version: required here, and must be 1
name: the row’s label in the tree — in a tasks.yml it is ignored at file level

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:.

To make a job wait on a sibling, give it needs: — one id, or a list of them, exactly as in a tasks.yml.


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.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.NOTES.summary }}.

The drafting task cannot act and the acting task cannot draft. That split is not a limitation waiting to be lifted — it is the reason a person is asked at all.


What you now have

Two formats, and a sense of which is which:

  tasks.yml workflows/*.yml
Is a library of commands one pipeline
Ordering needs: between tasks stages:, then needs: between jobs
Run granularity any task, alone the whole file
version: optional required

Next: your first spec — the same engine, running requirements, design, plan and implementation as a process whose prompts are files in your repository.

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.


Back to top

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