DEV Community

Mukesh
Mukesh

Posted on

Building a CircleCI Orb From Scratch: Test It Locally Before You Ever Publish

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
Enter fullscreen mode Exit fullscreen mode

@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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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 >>
Enter fullscreen mode Exit fullscreen mode

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) >>
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)