BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Publish a module: turn a task into a shareable package

You've got a task other people keep asking for. Here's how to wrap it in a manifest, validate it, tag a version, and let anyone import it with a single Git URL.

Jul 7, 2026 OrchStep Team 7 minROLE: OSS MaintainerSCALE: Any

You wrote a task that does one thing well — clones a repo the right way, posts a release note, validates a JSON payload — and now three people have asked for it. You could keep pasting YAML into Slack. Or you could publish it once and hand them a source: line.

Publishing an OrchStep module is deliberately unglamorous: there's no account to create and no server to run. A single-module repo is just your two module files at the repo root, plus a Git tag. Anyone can import it by URL. This post walks the whole path — manifest, validate, tag — using the real public example repo github.com/orchstep/test-module-single.

Anatomy of an OrchStep module: a manifest declaring metadata, permissions, config schema, and exports, alongside the workflow that implements them

The layout: two files at the repo root

For a single-module repo, the whole repository is the module. Put orchstep-module.yml and orchstep.yml at the root, and that's it. Here's a complete, validate-passing module:

orchstep-module.yml
metadata:
  name: slack-notify
  version: "1.1.0"
  description: "Post a release announcement to a chat channel"
  author: "your-org"
  tags: ["notify", "release", "chatops"]

permissions:           # declare least privilege
  shell: true
  http:
    allow: []          # no outbound hosts allowed
  git: false

config:                # config consumers may pass on the import
  schema:
    properties:
      channel:
        type: string
        required: false
        default: "#releases"
        desc: "Channel to post to"

exports:               # the public API: tasks consumers can call
  announce:
    desc: "Announce a release to the team channel"
    params:
      - name: service
        type: string
        required: true
      - name: version
        type: string
        required: false
        default: "latest"
    returns:
      type: object
      properties:
        message:
          type: string

Two rules the validator will enforce, and people get wrong constantly:

  • Identity fields (name, version, description) live under metadata: — not at the top level of the manifest.
  • exports and config are top-level keys, siblings of metadata: and permissions:.

The workflow is a normal OrchStep workflow. Its tasks: implement what exports: promises, defaults: carry the config values, and each task's outputs: satisfy the export's returns:.

Validate before you tag

OrchStep ships a validator so you catch problems before a consumer does. Run it against the directory:

orchstep module validate ./
✓ orchstep-module.yml valid
✓ orchstep.yml valid
Exports: [announce]

Structural problems — missing manifest fields, an export with no matching task — are errors. Supply-chain concerns — dangerous shell patterns, non-HTTPS or internal URLs, oversized files — are warnings. Add --strict to promote the warnings to errors:

orchstep module validate ./ --strict

That's the same gate the registry applies to AI-submitted modules, so passing --strict is a good habit even for your own repos.

Tag a version — that's the release

There's no publish command for a public repo. A Git tag is the release. Single-module repos use plain vX.Y.Z tags:

git tag v1.0.0 && git push origin v1.0.0

The real example repo, github.com/orchstep/test-module-single, carries three tags — v1.0.0, v1.1.0, and v2.0.0 — so you can watch semver resolution work for real. A consumer asking for ^1.0.0 gets v1.1.0 (the highest compatible 1.x), and is protected from the breaking v2.0.0:

modules:
  - name: greeter
    source: "github.com/orchstep/test-module-single"  # tags: v1.0.0, v1.1.0, v2.0.0
    version: "^1.0.0"                                  # resolves to v1.1.0

tasks:
  main:
    steps:
      - name: hi
        module: greeter
        task: hello
        with:
          name: "OrchStep"

When you ship a bug fix, tag v1.1.1; consumers on ^1.0.0 pick it up automatically. When you make a breaking change, tag v2.0.0; they stay on 1.x until they opt in. That's the contract semver buys you, and it's the entire reason to publish instead of paste.

What you gained

ConcernA pasted snippetA published module
DistributionSlack messagesource: + a Git URL
Versioning"which copy?"semver tags (^1.0.0v1.1.0)
Breaking changessilently break everyonea v2.0.0 tag nobody pulls by accident
The contractimplieddeclared in exports:
Trustread the YAML and hopeorchstep module validate --strict
Infra to runnonenone — Git is the registry

Beyond a single repo

Two paths open up once you've published one module:

  • Many modules, one repo. A monorepo layout hosts several modules under your own scope at modules/@yourscope/<name>/, each tagged <name>/vX.Y.Z, consumed via a registries: block. Good when you maintain a family of modules.
  • Private/internal modules. Module fetching is anonymous Git, so private repos aren't reachable through a URL source. For internal-only kits, OrchStep Pro adds a private registry (orchstep module publish@yourco/<name>) that keeps the same import ergonomics behind your own walls.

When publishing is overkill

If a task only ever runs inside one repo, don't publish it — keep it as a local module folder or a plain task. The cost of publishing isn't the tooling (there's barely any); it's the commitment to a stable contract. Once someone depends on your ^1.0.0, every change you make is a compatibility decision. Take that on when you have real consumers, not before.

Where to go next

Have a task people keep asking for? Add a manifest, run orchstep module validate ./, tag v1.0.0. Full format in Creating Modules.

#MODULES#PUBLISHING#VERSIONING#OPEN-SOURCE
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY