BLOG/TEAM CI/CD
THEME
TEAM CI/CD

A semver release pipeline, end to end

Preflight checks, version bump, changelog, tag, build, and a channel-gated publish — the whole release ritual as one readable workflow you can dry-run before you cut the tag.

May 28, 2026 OrchStep Team 7 minROLE: OSS MaintainerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/semver-release
VIEW SOURCE

If you maintain a package, you know the release ritual by heart, which is exactly the problem. It lives in your head and in a RELEASING.md that's half-right. Make sure the tree is clean. Run the tests one more time. Bump the version. Update the changelog. Tag it. Build the artifacts. Publish — but to the right channel, because shipping a beta to stable is the kind of mistake you only make once before you automate it away.

Most of these steps are one command. The value isn't in any single command — it's in doing them in order, every time, and being able to see the plan before the tag is permanent. This post writes the whole pipeline as one OrchStep workflow.

Preflight as its own task

Fail fast before touching anything. The checks live in their own task so you can run them on every PR, not just at release time, and the release calls them as its first step:

orchstep.yml
name: release
# orchstep run publish --var channel=stable
defaults:
  bump: patch
  channel: stable
  version: "1.4.2"

tasks:
  # Fail fast before we touch anything.
  preflight:
    steps:
      - { name: clean_tree, func: shell, do: 'echo "working tree clean"' }
      - { name: tests,      func: shell, do: 'echo "test suite green"' }
      - { name: lint,       func: shell, do: 'echo "lint clean"' }

  # orchstep run publish --var bump=minor --var channel=stable
  publish:
    steps:
      - name: preflight
        task: preflight

      - name: bump
        func: shell
        do: echo "1.4.3"
        outputs:
          next: '{{ result.output | trim }}'

      - name: changelog
        func: shell
        do: echo "writing CHANGELOG entry for v{{ steps.bump.next }}"

      - name: tag
        func: shell
        do: echo "tagging v{{ steps.bump.next }}"

      - name: build
        func: shell
        do: echo "building release artifacts for v{{ steps.bump.next }}"

      - name: gate
        if: '{{ eq vars.channel "stable" }}'
        then:
          - name: publish_stable
            func: shell
            do: echo "publishing v{{ steps.bump.next }} to the stable channel"
        else:
          - name: publish_pre
            func: shell
            do: echo "publishing v{{ steps.bump.next }} to the {{ vars.channel }} channel"
        finally:
          - name: announce
            func: shell
            do: echo "release v{{ steps.bump.next }} done"

The mechanics worth pointing at:

  • preflight is a reusable task. The release calls it with - { name: preflight, task: preflight }. The same checks gate your PRs and your releases, defined once.
  • bump produces the next version as an output. Every downstream step reads steps.bump.next, so the version is computed in one place and quoted everywhere consistently. Replace its echo with your real bump tool (npm version, cargo set-version, a git describe calc) and nothing else changes.
  • gate is the channel guard. Stable releases publish to stable; anything else (beta, rc, nightly) goes to its own channel. The mistake of shipping a pre-release to stable is now a branch you can read, not a flag you might forget.
  • finally: always announces. The release isn't "done" until it's recorded, whether the publish was stable or pre-release.

Cut a release

orchstep run publish --var channel=stable
orchstep run publish --var bump=minor --var channel=beta

The --var overrides let you choose the bump size and the channel at the call site without editing the file. A maintainer cutting 1.5.0-beta.1 and one cutting 1.4.3 run the same workflow.

Dry-run before the tag is forever

A tag is the one step you can't quietly take back. So look before you leap:

orchstep run publish --var channel=beta --dry-run

The plan resolves the next version, shows the changelog and tag steps, and proves the beta path takes the pre-release branch — all without creating a tag or pushing a thing. You get to confirm "yes, this publishes to beta, not stable" while it's still costless. See Previewing with Dry Run.

What you actually gained

StepRELEASING.mdOrchStep
Preflight checks"remember to run tests"a called preflight task
Next versiontyped in three placesone bump output
Channel safetya flag you hope you setif/else gate
Always announcemanualfinally:
"Is this right?"find out after pushing--dry-run

Where OrchStep stops

OrchStep orders and gates the release; it doesn't replace your bump tool, changelog generator, or registry client. The echos are placeholders for git tag, npm publish, cargo publish, GoReleaser, or whatever you already use. If your language's tooling already does the whole dance in one command and you're happy with it, keep it. This pays off when the ritual is spread across several tools and a doc, and you want it to be one workflow you can preview.

Where to go next

Runnable as-is — every step only echos. orchstep run publish --var channel=beta --dry-run shows the channel gate picking the pre-release path.

#RELEASE#SEMVER#CHANGELOG#PUBLISHING
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD