Writing a new workflow

From an empty file to a release pipeline with a human gate.

  1. 1. The smallest workflow that runs
  2. 2. Add a second job that runs alongside
  3. 3. Add a stage
  4. 4. Tasks are a list, and needs: is not allowed on them
  5. 5. Ask the caller for a value
  6. 6. Stop for a human
  7. 7. Pass data between jobs
  8. 8. Declare a plugin once
  9. 9. A stage that runs nothing
  10. 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: and required: true together is an error. A param with a default is never missing, so required: 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 called work-item could 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.


Back to top

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