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
| Capability | Where |
|---|---|
| Guard rails before side effects | preflight_* + enforce_preflight (assert) |
| Computation in JavaScript | compute_next_version (transform) |
| Step outputs chaining | version flows through 6 steps via steps.x.y |
| Branching on runtime input | publish gates only the production channel |
| Human approval | prompt confirm inside the gated branch |
| Post-conditions | verify_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=minorVerified 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.0Design 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 outputsChannels 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.