DOC_INDEX
THEME
DOCS/Use Cases/Release Automation

Release Automation

A real release pipeline - preflight checks, semver bump, changelog, git tag, build artifact, and channel-gated publishing

A complete, runnable release pipeline. One orchstep run release cuts a release end to end: it refuses to run from a dirty tree or the wrong branch, computes the next semver in JavaScript, updates CHANGELOG.md, commits and tags with real git, builds a checksummed artifact, and publishes it to a channel registry - with an approval gate when the channel is production.

Everything on this page was executed against OrchStep v0.7.1; the outputs shown are real.

What it demonstrates

CapabilityWhere
Guard rails before side effectspreflight_* + enforce_preflight (assert)
Computation in JavaScriptcompute_next_version (transform)
Step outputs chainingversion flows through 6 steps via steps.x.y
Branching on runtime inputpublish gates only the production channel
Human approvalprompt confirm inside the gated branch
Post-conditionsverify_release asserts tag and registry location

The workflow

name: release-pipeline
desc: "Cut a release: preflight, version bump, changelog, tag, build, publish to a channel registry"

defaults:
  repo_dir: "./demo-repo"
  registry_root: "./registry"
  channel: "staging"
  bump: "patch"

tasks:
  setup_demo_repo:
    desc: "Create a throwaway git repo to release from (demo helper)"
    steps:
      - name: scaffold
        func: shell
        do: |
          rm -rf {{ vars.repo_dir }}
          mkdir -p {{ vars.repo_dir }}/src
          cd {{ vars.repo_dir }}
          git init -q -b main
          git config user.email demo@orchstep.dev
          git config user.name "OrchStep Demo"
          echo "1.4.1" > VERSION
          echo 'print("checkout service")' > src/app.py
          printf "# Changelog\n\n" > CHANGELOG.md
          git add -A && git commit -qm "baseline 1.4.1"
          echo "repo ready"

  release:
    desc: "Run the full release (use --var channel=production for prod)"
    steps:
      - name: preflight_clean_tree
        desc: "Refuse to release from a dirty working tree"
        func: shell
        do: |
          cd {{ vars.repo_dir }}
          test -z "$(git status --porcelain)" && echo "tree=clean"
        outputs:
          tree: '{{ result.output | regexFind "tree=(\\w+)" }}'

      - name: preflight_branch
        func: shell
        do: cd {{ vars.repo_dir }} && git rev-parse --abbrev-ref HEAD
        outputs:
          branch: "{{ result.output }}"

      - name: enforce_preflight
        func: assert
        args:
          conditions:
            - condition: '{{ eq steps.preflight_clean_tree.tree "clean" }}'
              desc: "working tree must be clean"
            - condition: '{{ eq steps.preflight_branch.branch "main" }}'
              desc: "releases cut from main only"

      - name: read_version
        func: shell
        do: |
          cat {{ vars.repo_dir }}/VERSION
          echo "bump={{ vars.bump }}"
        outputs:
          current: '{{ result.output | regexFind "^([0-9.]+)" }}'
          bump: '{{ result.output | regexFind "bump=([a-z]+)" }}'

      - name: compute_next_version
        desc: "Semver bump in JavaScript (transform)"
        func: transform
        do: |
          // Runtime --var values are not visible on the JS `vars` object and
          // transform code is not template-rendered (engine quirks), so the
          // bump type is staged through the previous step's outputs.
          const [maj, min, pat] = steps.read_version.current.split(".").map(Number);
          const bump = steps.read_version.bump;
          const next = bump === "major" ? `${maj+1}.0.0`
                     : bump === "minor" ? `${maj}.${min+1}.0`
                     : `${maj}.${min}.${pat+1}`;
          return { next: next, tag: "v" + next };

      - name: bump_and_changelog
        func: shell
        do: |
          cd {{ vars.repo_dir }}
          echo "{{ steps.compute_next_version.next }}" > VERSION
          DATE=$(date +%Y-%m-%d)
          printf "# Changelog\n\n## {{ steps.compute_next_version.tag }} - ${DATE}\n- automated release ({{ vars.bump }} bump)\n\n" > CHANGELOG.tmp
          tail -n +2 CHANGELOG.md >> CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
          git add -A && git commit -qm "release {{ steps.compute_next_version.tag }}"
          git tag {{ steps.compute_next_version.tag }}
          git describe --tags
        outputs:
          tagged: "{{ result.output }}"

      - name: build_artifact
        func: shell
        do: |
          cd {{ vars.repo_dir }}
          tar -czf ../{{ vars.channel }}-build.tgz src VERSION CHANGELOG.md
          shasum -a 256 ../{{ vars.channel }}-build.tgz | cut -d' ' -f1
        outputs:
          sha256: "{{ result.output }}"

      - name: publish
        desc: "Publish to the channel registry; production channel is gated"
        if: '{{ eq vars.channel "production" }}'
        then:
          - name: confirm_prod
            func: prompt
            args:
              message: "Publish {{ steps.compute_next_version.tag }} to PRODUCTION?"
              type: confirm
              default: "true"
          - name: gate
            func: assert
            args:
              condition: '{{ eq steps.confirm_prod.value "true" }}'
        else:
          - name: nonprod_note
            func: shell
            do: 'echo "non-production channel: no approval needed"'

      - name: copy_to_registry
        func: shell
        do: |
          DEST={{ vars.registry_root }}/{{ vars.channel }}/{{ steps.compute_next_version.tag }}
          mkdir -p $DEST
          cp {{ vars.channel }}-build.tgz $DEST/
          echo "{{ steps.build_artifact.sha256 }}" > $DEST/SHA256
          echo "published=$DEST"
        outputs:
          location: '{{ result.output | regexFind "published=(.+)" }}'

      - name: verify_release
        func: assert
        args:
          conditions:
            - condition: '{{ contains steps.compute_next_version.tag steps.bump_and_changelog.tagged }}'
              desc: "git tag matches computed version"
            - condition: '{{ contains vars.channel steps.copy_to_registry.location }}'
              desc: "artifact landed in the right channel"

      - name: summary
        func: shell
        do: 'echo "RELEASED {{ steps.compute_next_version.tag }} sha={{ steps.build_artifact.sha256 }} -> {{ steps.copy_to_registry.location }}"'

Run it

# one-time: create the throwaway demo repo
orchstep run setup_demo_repo

# patch release to the staging channel (no approval needed)
orchstep run release

# minor release to production (prompts for approval)
orchstep run release --var channel=production --var bump=minor

Verified results from those exact commands:

RELEASED v1.4.2 sha=beb6b7cb... -> ./registry/staging/v1.4.2
RELEASED v1.5.0 sha=ef1651b3... -> ./registry/production/v1.5.0
$ git -C demo-repo tag
v1.4.2
v1.5.0

Design notes

Preflight before side effects. The first three steps make the pipeline refuse to run from a dirty tree or a non-main branch. Putting the assert in its own enforce_preflight step keeps the checks reusable and the failure message precise.

The transform bridge. Transform code is not template-rendered, and on engines before v0.8.0 the JS vars object also did not see --var overrides (fixed in v0.8.0). Staging values through a previous step's outputs works on every version - JS always sees steps.*:

- name: read_version
  func: shell
  do: |
    cat VERSION
    echo "bump={ vars.bump }"     # template sees --var overrides
  outputs:
    current: '{ result.output | regexFind "^([0-9.]+)" }'
    bump: '{ result.output | regexFind "bump=([a-z]+)" }'

- name: compute_next_version
  func: transform
  do: |
    const bump = steps.read_version.bump;   // JS sees step outputs

Channels as variables, gates as branches. --var channel=production flips both the registry path and the approval requirement without touching the workflow. The if/else on publish is where policy lives.

Take it to production

Swap the demo pieces for your real ones: point repo_dir at your repo, replace the registry copy with gh release create, npm publish, or a container push, and put the real release notes in the changelog step. The skeleton - preflight, compute, mutate, verify - stays the same.