Most teams that use CircleCI never write an orb — they copy-paste the same run steps across every .circleci/config.yml in the org, and when the deploy script needs a fix, they edit it in twelve repos one at a time. An orb turns that copy-pasted YAML into a versioned, testable package you import with one line. The blocker isn't the concept, it's that the docs jump straight to "publish to the registry," which means your first feedback loop is a real publish, a real version bump, and a real pipeline run just to catch a typo in a parameter name. That loop is slow enough that most people give up after the second failed publish.
This walkthrough builds a small but real orb — one that posts a deploy notification to Slack with the git SHA, branch, and pipeline URL — and gets you to a fully working, locally-validated package before you ever touch the registry.
Project layout
CircleCI orbs are just YAML, organized into src/ so the CLI can pack them into a single distributable file:
slack-deploy-notify/
├── src/
│ ├── @orb.yml
│ ├── commands/
│ │ └── notify.yml
│ ├── jobs/
│ │ └── announce.yml
│ └── examples/
│ └── basic_usage.yml
└── test-deploy/
└── .circleci/
└── config.yml
@orb.yml is the entry point that stitches the pieces together. Install the CLI first, since every step below depends on it:
curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/install.sh | bash
circleci update
circleci setup # paste a personal API token when prompted
Writing the command
A command is the reusable unit — a named, parameterized set of steps other jobs can call. Here's src/commands/notify.yml:
description: >
Posts a deploy notification to a Slack webhook, including the branch,
commit SHA, and a link back to the CircleCI pipeline.
parameters:
webhook:
type: env_var_name
default: SLACK_DEPLOY_WEBHOOK
description: Name of the env var holding the Slack incoming webhook URL.
message:
type: string
default: "Deploy started"
description: Text to prefix the notification with.
steps:
- run:
name: Notify Slack of deploy
command: |
PAYLOAD=$(cat <<EOF
{
"text": "<< parameters.message >> — \`${CIRCLE_BRANCH}\` @ \`${CIRCLE_SHA1:0:7}\` — <${CIRCLE_BUILD_URL}|view pipeline>"
}
EOF
)
curl -sf -X POST -H 'Content-Type: application/json' \
-d "$PAYLOAD" "${!parameters.webhook}"
The env_var_name parameter type is the detail people miss: it lets the caller point at whichever env var holds their secret, instead of you hardcoding SLACK_DEPLOY_WEBHOOK and forcing every consumer to name their context variable exactly that. ${!parameters.webhook} does the indirection — bash resolves the parameter to a name, then dereferences that name.
Writing the job
A job wraps the command in an executor. src/jobs/announce.yml:
description: Runs the Slack deploy notification as a standalone job.
parameters:
message:
type: string
default: "Deploy started"
executor: default
steps:
- slack-deploy-notify/notify:
message: << parameters.message >>
And the executor plus orb entry point, src/@orb.yml:
version: 2.1
description: Post deploy notifications to Slack with git context baked in.
executors:
default:
docker:
- image: cimg/base:2026.06
commands:
notify: << include(commands/notify.yml) >>
jobs:
announce: << include(jobs/announce.yml) >>
examples:
basic_usage: << include(examples/basic_usage.yml) >>
(In a real repo you write full YAML in each file rather than a literal include() macro — the CLI's orb pack command does this stitching for you at pack time, shown next.)
Packing and validating — before you publish anything
This is the step that actually shortens the feedback loop. Pack the src/ tree into one file and validate it locally, with zero network calls to the registry:
circleci orb pack src > orb.yml
circleci orb validate orb.yml
validate catches the errors that would otherwise only surface as a failed publish: malformed parameter types, a job referencing an executor that doesn't exist, YAML anchors that don't resolve. Run this after every edit — it takes under a second and it's the reason you don't need a real pipeline run to catch a typo.
Testing it against a real pipeline without publishing
Validation confirms the YAML is well-formed; it does not confirm the steps actually work. For that you need a real CircleCI run, but you still don't need the public registry — publish to a dev release, which is namespaced, ephemeral, and meant for exactly this:
circleci orb publish orb.yml your-namespace/slack-deploy-notify@dev:first-try
Dev releases expire after 90 days and don't count against your orb's public version history, so you can publish as many @dev:* tags as you need while iterating. Point a scratch repo's config at it:
# test-deploy/.circleci/config.yml
version: 2.1
orbs:
notify: your-namespace/slack-deploy-notify@dev:first-try
workflows:
test-notify:
jobs:
- notify/announce:
message: "Testing the orb"
context: slack-secrets # holds SLACK_DEPLOY_WEBHOOK
Push that, watch the job run in the CircleCI UI, and confirm the Slack message lands with the right branch and SHA. Iterate by re-running orb pack + orb publish ... @dev:first-try (same tag overwrites) and re-triggering the pipeline — no version bump needed until the orb actually works end to end.
Promoting to a real version
Once the dev release behaves, cut a semantic version. Orbs follow strict semver and each tag is immutable once published — you can't overwrite 1.0.0, only publish 1.0.1:
circleci orb publish orb.yml your-namespace/slack-deploy-notify@1.0.0
Unlisted namespaces require a one-time circleci namespace create and circleci orb create before the first publish; if your org already publishes other orbs, skip straight to orb publish. Update the scratch config to your-namespace/slack-deploy-notify@1.0.0, confirm it still runs, and you now have a versioned dependency instead of duplicated YAML.
The two mistakes that waste the most time
Treating orb validate as sufficient. It only checks structure, not runtime behavior — a command that references << parameters.webhook >> correctly but resolves to an empty env var still validates cleanly and then fails silently in curl. Always run the dev-release pipeline test before cutting a version.
Publishing to a public version tag while iterating. Public orb versions are immutable and visible in the registry's version history the moment they're published — including to anyone who's already pinned an earlier tag and gets a surprise on next fetch if you're sloppy about what "iterating" means. @dev:* tags exist precisely so your rough drafts never touch that history. If you find yourself about to publish 1.0.1 just to fix a typo you noticed thirty seconds after 1.0.0, that's the signal you skipped the dev-release loop.
With this pattern — pack, validate, dev-publish, test against a scratch pipeline, then version — you get the same fast inner loop you'd expect from testing any other piece of code, instead of treating "publish to CircleCI" as your only feedback signal.
Top comments (0)