BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Consume a remote module from Git

Stop copy-pasting the same task block between repos. Point a module source at a Git URL, pin a semver range, and call its exported task — OrchStep fetches, caches, and pins it for you.

Jul 6, 2026 OrchStep Team 6 minROLE: Backend DeveloperSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/consume-remote-module
VIEW SOURCE

You wrote a tidy "checkout this repo, build, and tag the image" task block. It was good. So good that it now lives — slightly diverged — in four repos. One of them still has the bug you fixed in the other three.

That is the copy-paste tax, and it is exactly what a module pays off. A module is a versioned workflow you import by reference instead of by clipboard. This post takes the simplest possible case: a module that lives in a public Git repo, imported with a semver range and called from a step.

A module source resolving from a Git URL to a cached, pinned version

Import by reference, not by copy

A workflow imports modules in a top-level modules: block. Each import gets a local name: alias and a source:. For a single-module repo — one where the module's files live at the repo root — the source is just the Git URL, and version: is a semver constraint.

orchstep.yml
name: consume-remote-module
desc: "Import a versioned module straight from a Git repo and call its exported task."

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:
  # `orchstep run hello`
  hello:
    desc: "Call the remote module's exported task"
    steps:
      - name: greet
        module: greeter          # the alias from modules:
        task: hello              # an exported task
        with:
          name: "OrchStep"

Three things to notice in the step:

  • module: greeter is the alias you declared above, not the repo name.
  • task: hello is an exported task of that module. module: and task: are separate keys — a dotted task: greeter.hello is invalid.
  • with: passes the task's parameters.

Run it

orchstep run hello

The first run does the resolution and fetch for you:

🔍 Resolving 'greeter' version ^1.0.0 from https://github.com/orchstep/test-module-single
📦 Resolved 'greeter' → v1.1.0 (tag: v1.1.0, commit: 8638d85ce4e0)
📦 Cached at: /Users/you/.orchstep/cache/modules/github.com/orchstep/test-module-single/v1.1.0
📦 Loaded module 'greeter' from 'github.com/orchstep/test-module-single'
🔒 Wrote orchstep.lock (1 module(s) pinned)
Workflow: consume-remote-module
Task: hello
  Step: greet
  $ echo 'Hello, OrchStep! (v1.1.0)'
Hello, OrchStep! (v1.1.0)
  [ok] greet
Result: success

The repo has tags v1.0.0, v1.1.0, and v2.0.0. Your ^1.0.0 constraint means "the newest 1.x", so it picked v1.1.0 and skipped the breaking v2.0.0. The fetched copy lands in ~/.orchstep/cache/modules/, keyed by repo path and version, so the second run is offline-fast.

Look before you import

You do not have to read someone's tags off a webpage. module info prints the Git URL and every available version:

orchstep module info github.com/orchstep/test-module-single
Module: github.com/orchstep/test-module-single
Git URL: https://github.com/orchstep/test-module-single

Available versions:
   v1.0.0
   v1.1.0
   v2.0.0

module resolve answers the next question — "given my constraint, which one do I actually get?":

orchstep module resolve github.com/orchstep/test-module-single ^1.0.0
# Resolved: v1.1.0 (tag: v1.1.0)

And module search finds modules in the public registry by keyword, while module install warms the cache ahead of a run (handy in CI bootstrap):

orchstep module search git-checkout
# @community/github_action_git-checkout  v1.0.0  Clone Git repos and checkout refs ...  [community]

orchstep module install github.com/orchstep/test-module-single@v1.0.0

Choosing a version constraint

The version: field is a standard semver range. Pick the one that matches how much you trust the upstream to honor semver:

ConstraintMatchesYou get
1.1.0exact only1.1.0, forever
^1.0.0>=1.0.0 <2.0.0newest 1.x (here 1.1.0)
~1.0.0>=1.0.0 <1.1.0newest patch of 1.0
>=1.0.0minimumnewest tag, including 2.0.0
*anythe highest tag

^1.0.0 is the sane default: you get bug fixes and features, but a major bump (2.0.0) never sneaks in behind your back.

What you gained

ConcernCopy-pasteRemote module
One source of truthfour diverged copiesone repo, imported by reference
Upgradesedit every repobump the version: constraint
"What version am I on?"read the diffmodule info / module resolve
Offline + repeatablere-clone by handcached in ~/.orchstep/cache
Trust boundarynonesemver range you control

The honest boundary

A module is worth it when a task block is shared across repos or teams, or when its logic is gnarly enough to deserve its own tests and tags. If a task lives in exactly one workflow and always will, leave it inline — importing it from a Git repo is pure overhead. And remote fetching uses anonymous Git, so the source repo has to be reachable without credentials (public, today).

One more thing: that orchstep.lock the first run wrote is not incidental. It pins the exact commit and content hash behind the tag, which is what makes the resolution reproducible for your teammates and CI. That is its own post — see the lockfile link below.

Where to go next

Have a task block living in four repos? Move it into one, tag it v1.0.0, and import it. The shape above is the whole consumer side.

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

RELATED — DEVELOPER DAILY