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.
blog/semver-releaseIf 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:
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:
preflightis a reusable task. The release calls it with- { name: preflight, task: preflight }. The same checks gate your PRs and your releases, defined once.bumpproduces the next version as an output. Every downstream step readssteps.bump.next, so the version is computed in one place and quoted everywhere consistently. Replace itsechowith your real bump tool (npm version,cargo set-version, agit describecalc) and nothing else changes.gateis the channel guard. Stable releases publish tostable; 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=betaThe --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-runThe 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
| Step | RELEASING.md | OrchStep |
|---|---|---|
| Preflight checks | "remember to run tests" | a called preflight task |
| Next version | typed in three places | one bump output |
| Channel safety | a flag you hope you set | if/else gate |
| Always announce | manual | finally: |
| "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
- Variables & Outputs — computing a version once and reusing it
- Error Handling —
catch,finally, retries on a flaky publish - Previewing with Dry Run — resolve the release plan, execute nothing
Runnable as-is — every step only echos. orchstep run publish --var channel=beta --dry-run shows the channel gate picking the pre-release path.
curl -fsSL https://orchstep.dev/install.sh | sh