BLOG/TEAM CI/CD
THEME
TEAM CI/CD

GitHub Actions without the YAML sprawl

Your .github/workflows folder has six files and 400 lines you can't run locally. Move the logic into orchstep tasks and let the workflow do one job: call them.

Jun 4, 2026 OrchStep Team 6 minROLE: DevOps EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/github-actions-tasks
VIEW SOURCE

Open .github/workflows/ on any repo older than a year. There's ci.yml, release.yml, nightly.yml, and three more, each 60-plus lines of steps with inline run: blocks, if: github.event_name == ... guards, and a matrix you copy-pasted between two of them. None of it runs on your laptop. All of it is GitHub-specific. When a step is wrong, you debug it by pushing commits.

The sprawl isn't GitHub's fault — it's that the workflow file is doing two jobs at once: triggering (on push, on schedule, on a matrix) and defining the work. GitHub is great at the first. It's a poor place to keep the second.

So split them. Triggering stays in the workflow. The work moves into orchstep tasks you can run anywhere.

The work lives in tasks

Here's the pipeline logic — setup, test, build — as plain tasks. The test task takes a suite variable so one definition covers the whole matrix:

orchstep.yml
name: app
# The GitHub workflow stays thin: it just calls these tasks.
# Logic lives here, where you can also run it on your laptop.
defaults:
  suite: unit

tasks:
  # `orchstep run setup`
  setup:
    steps:
      - name: deps
        func: shell
        do: echo "restoring dependencies"

  # `orchstep run test --var suite=integration` — one task, matrix-friendly
  test:
    steps:
      - name: run
        func: shell
        do: echo "running {{ vars.suite }} suite"
      - name: annotate
        if: '{{ eq vars.suite "integration" }}'
        then:
          - name: note
            func: shell
            do: echo "::notice::integration suite finished"

  # `orchstep run build`
  build:
    steps:
      - name: compile
        func: shell
        do: echo "building release artifact"
        outputs:
          artifact: "dist/app.tar.gz"
      - name: report
        func: shell
        do: echo "built {{ steps.compile.artifact }}"

That annotate step prints a ::notice:: line — GitHub renders those as annotations on the run, so you get the nice UI markers without coupling the logic to GitHub. Run the task locally and you just see the line in your terminal.

The workflow gets thin

Now the GitHub side does one thing: trigger and call. The matrix that used to wrap inline shell steps now varies a single --var:

workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        suite: [unit, integration]
    steps:
      - uses: actions/checkout@v4
      # The official action installs the CLI and runs the task in one step.
      # (Prefer a manual install? Replace it with:
      #    - run: curl -fsSL https://orchstep.dev/install.sh | sh
      #    - run: orchstep run test --var suite=${{ matrix.suite }} )
      - uses: orchstep/run-orchstep@v1
        with:
          workflow: orchstep.yml
          task: test
          vars: |
            suite=${{ matrix.suite }}

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: orchstep/run-orchstep@v1
        with:
          workflow: orchstep.yml
          task: build

The matrix is still GitHub's — that's the part GitHub is genuinely good at. What changed is that the body of each job is now a one-liner. There's no inline shell to drift, no logic you can't reproduce locally. To add a contract suite, you add it to the matrix list; the task already handles any suite value.

Two official actions

There are two maintained actions, so you don't hand-roll the install:

  • orchstep/run-orchstep@v1 — installs the CLI and runs a task (or lint / list-tasks) in one step. It accepts task, vars, env, output, and json-file, and adds inline ::error annotations on failure. That's the one used above.
  • orchstep/setup-orchstep@v1 — just installs (and caches) the binary, for a job that runs several orchstep commands:
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: orchstep/setup-orchstep@v1
        with:
          version: latest        # or pin a semver
      - run: orchstep lint
      - run: orchstep validate release --strict
      - run: orchstep run release --env production

Prefer no action at all? The manual curl -fsSL https://orchstep.dev/install.sh | sh still works everywhere — the binary is the binary. The actions just save you the install boilerplate and add the annotations.

Reproduce the matrix on your laptop

Each matrix cell is just a task call, so you run any cell directly:

orchstep run test --var suite=integration

No act, no pushing a debug commit, no waiting for the runner. The thing that failed in CI is the same command in your shell.

What you actually gained

ConcernInline workflow stepsorchstep tasks
Run a job locallyinstall act, or pushorchstep run <task>
Matrix bodyduplicated inline shellone task, varies a --var
AnnotationsGitHub-only ::notice:: stringsa step you can also run locally
Add a release pipelineanother 60-line YAML fileanother task in the same file
Switch CI providersrewrite every workflowrewrite the thin glue

The honest boundary: triggering, secrets injection, and the matrix fan-out belong in GitHub Actions, and OrchStep doesn't try to replace them. What it replaces is the 300 lines of pipeline logic that ended up in YAML because that's where the runner could see it.

Where to go next

Got a .github/workflows folder you can't run locally? Move the bodies into tasks — curl -fsSL https://orchstep.dev/install.sh | sh.

#GITHUB-ACTIONS#CI#MATRIX#DEVOPS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD