One app package serves many versions of the software it installs. A person picking your app from the catalog picks a version too — 1.7.1, 20.11.0, 3.46 — and your install script is handed that string and asked to produce exactly it. This page is the author's side of that: how to install a pinned upstream version so that it works, and how to tell ORC8R which versions exist in the first place. It follows on from Authoring apps, which covers the rest of the package.

The version is the upstream project's version

APP_VERSION is set for every lifecycle phase and carries the upstream version string — the version the project itself publishes and tags, the same string the catalog offered. It is not a version of your package. Publishing myapp:default once is enough to serve every upstream release: one package, one parameter schema, one set of scripts, and a version string that varies per node.

Two consequences follow, and they are the whole discipline of this page.

Your install script must fetch that exact upstream artifact — the vendor archive, the release asset, the language-registry package for that version. Nothing else honours the choice the operator made.

An OS package manager is not a version source. apt-get install nodejs gives you whatever the distribution decided, which is not APP_VERSION and drifts under you. Package managers stay entirely legitimate inside an install script for system dependencies — build tools, shared libraries, a runtime your app links against — but never for the thing the app is named after.

APP_VERSION is unset in one case: when the resolved version is the implicit default tag, which is what you get for an app that declares no versions at all.

Installing a pinned version

Verify a published checksum

Every artifact your install script fetches must be checked against a checksum the vendor published, and the phase must fail on mismatch. This is not optional and not a nicety: an install script runs privileged — root on a typical Linux node — on every node of a pool at once, so an unverified download is an unattended remote-code path into all of them. Fetch the vendor's checksum file for that release, look your own filename up in it, and let the checksum tool decide:

curl -fsSLO "$base/$asset"
curl -fsSL "$base/sha256sum.txt" > sums.txt
grep " $asset\$" sums.txt | sha256sum -c -   # non-zero exit fails the phase

A pipeline that ends in a non-zero exit fails the install phase, which is what you want — a node that could not verify its download must not come up serving.

Keep the layout version-scoped

Several versions of one app may be installed on a node at the same time; ORC8R keys install state by app plus version and imposes no exclusivity. Collision avoidance is yours. Install into a path that carries the version:

PlatformLayout
Unix/opt/<app>/<version>/
Windows%ProgramFiles%\<app>\<version>\

That one habit buys three things: two versions can coexist, an uninstall can remove exactly one version's tree, and a half-finished install of a new version cannot corrupt the running old one.

Claim only your own subtree. On a node someone enrolled by hand, /opt/<app> may already exist from a manual install. Tolerate that — create it if missing, and treat only <version>/ beneath it as yours. An uninstall removes that subtree and nothing else, so it can never delete a directory it did not create.

Put the binary on PATH yourself

The runtime never edits PATH for you. Entry binaries are exposed by the install phase, and there is one correct way to do it per platform.

Unix: symlinks in /usr/local/bin, each pointing into the versioned tree. That directory is already on the default PATH everywhere, for every kind of process. Switching the active version is repointing those symlinks.

ln -sfn "/opt/jq/$APP_VERSION/bin/jq" /usr/local/bin/jq

Do not use profile.d fragments, or any other edit to a shell profile. They are read by interactive login shells and by nothing else — not by systemd units, not by the agent's own phase execution, not by a CI runner's non-login shell. An app exposed that way works when you SSH in to check it and fails everywhere it matters.

Windows: a current junction plus one machine PATH entry. Maintain %ProgramFiles%\<app>\current pointing at the active version, and add that single path to the machine PATH at first install, removing it at last uninstall. Switching versions repoints the junction and touches PATH not at all.

$root = "$env:ProgramFiles\jq"
New-Item -ItemType Junction -Path "$root\current" -Target "$root\$env:APP_VERSION" -Force

A machine PATH change reaches only processes started after it, so nothing already running sees the new entry — another reason the junction, not PATH, is what moves when a version changes.

An uninstall removes an exposure symlink or junction only while it points into that version's own tree, and leaves it alone otherwise.

target=$(readlink /usr/local/bin/jq 2>/dev/null || true)
case "$target" in
  "/opt/jq/$APP_VERSION"/*) rm -f /usr/local/bin/jq ;;
esac
rm -rf "/opt/jq/$APP_VERSION"

This is the rule that makes version switching safe, and the reason is ordering. Replacing 1.7.1 with 1.8.0 installs the new version first — which repoints the symlink at 1.8.0 — and then uninstalls 1.7.1. An uninstall that removed the link unconditionally would delete the working 1.8.0 exposure it just inherited, leaving a node with the software installed and nothing on PATH. Checking ownership first turns that into a no-op.

Installing X over Y must yield a working X

Whatever else your layout does, this holds: a node already carrying version Y, told to install version X, ends up with a working X. Coexistence is a should; replacement is a must. Test it — install one version, then install another over it without uninstalling, and confirm the binary on PATH reports the second.

The vendor-layout escape hatch

Some software owns its own layout and will not be told otherwise: a self-managing toolchain, an MSI with a fixed target, an installer that writes to one hard-coded directory. Use the vendor's layout in that case. The cost is explicit and bounded — this app supports no side-by-side installs — and the replacement rule above still applies. What you must not do is fight the installer into a versioned path it does not support and end up with a half-relocated tree.

Declaring where versions come from

config.versions tells ORC8R how to enumerate the versions your app can install, so the catalog offers them without you republishing the package for each release. config.default_version names the one selected by default.

config:
  default_version: "3.46"
  versions:
    source: github_releases
    url: https://github.com/jenkinsci/swarm-plugin
    filter:
      prerelease: false

Discovery is entirely declarative — a source, then a filter pipeline. There is no expression language to learn, and every host evaluates the same declaration identically.

Sources

sourceurlWhat it enumerates
omitted—the tags of your app's own repository
github_releasesGitHub repo URLpublished releases; understands prereleases and skips drafts
github_tagsGitHub repo URLrepository tags, for projects that tag releases without publishing GitHub releases
gitlab_releasesGitLab project URLpublished releases
gitlab_tagsGitLab project URLrepository tags
jsonany URLa JSON document — see below
httpany URLa plain-text response, one version per line; blank lines and # comments ignored
nuget / wingetoptional feed URLpackage versions, with the package id taken from the app name

Sources are fetched over https, under a per-fetch timeout and a response-size cap, and the result is capped at a hundred versions — write a filter that narrows to what you actually want to offer rather than relying on the cap to do it.

Leave source out entirely for tag-based discovery of your own repository. That is the default behaviour, and a source value that is not in the table above is not an error — it simply enumerates nothing, which looks exactly like an upstream that has published no releases yet. If a version list comes back empty, check the spelling of source first.

A fixed set of versions needs no source at all, which is the right answer for an app whose versions you control by hand:

config:
  versions:
    list: ["3.46", "3.45"]

The json source

Many projects publish a version index that is neither a GitHub release list nor a plain-text file. json reads one, using two fields no other source uses:

  • select — a JSON pointer to the node holding the versions (/versions, /data/releases). Omit it for the document root. It starts with /.
  • field — the property name carrying the version string. Required when the selected node is an array of objects.

The selected node is read by shape: an array of strings is the version list, an array of objects yields each element's field, and an object yields its keys. Anything else is an error. Node's distribution index is the canonical example — an array of objects at the root, each with a version like v20.11.0:

config:
  versions:
    source: json
    url: https://nodejs.org/dist/index.json
    field: version
    filter:
      pattern: '^v(?P<version>\d+\.\d+\.\d+)$'
      latest_per: minor

The filter pipeline

filter narrows what the source produced. Every field is optional and an omitted field makes its stage do nothing, but the stages always run in this fixed order — which matters, because it decides what each later stage is looking at:

  1. fetch — the source produces its raw strings.
  2. extract — pattern drops what does not match, and rewrites what does (below).
  3. filter — prerelease, then exclude.
  4. sort — sort, always descending.
  5. prune — latest_per.
  6. limit — limit truncates.
FieldDefaultEffect
pattern—a regular expression the version string must match; may also extract
prereleasefalseprereleases are dropped unless you set this to true
excludenoneexact version strings to drop — for a release you know is broken
sortsemversemver, numeric, or string; always newest first
latest_pernonemajor, minor, or none
limitnonekeep at most N versions after sorting

The regular-expression dialect has no backtracking, so evaluation is bounded and portable. An invalid sort, latest_per, or pattern is a loud error rather than a silent no-op.

Because exclude runs after extraction, it matches the version as extracted. If your pattern turns tag jq-1.7.1 into 1.7.1, exclude 1.7.1, not jq-1.7.1.

Extracting a version out of a tag

Upstream tags are often not the version you want to show. jq tags releases jq-1.7.1; plenty of projects prefix with v. Extraction fixes that, and it is opt-in through a named capture group:

filter:
  pattern: '^jq-(?P<version>\d+\.\d+(\.\d+)?)$'

When pattern contains (?P<version>...), that group's text becomes the version string — tag jq-1.7.1 becomes version 1.7.1. Everything downstream sees the extracted string: exclude, sorting, latest_per, the catalog, and APP_VERSION itself. Your install script therefore receives 1.7.1 and rebuilds the tag it needs as jq-$APP_VERSION.

Three details decide whether this behaves:

  • A pattern with no named group is a filter and nothing else. Matching versions are carried through verbatim. This includes a pattern using ordinary capture groups — the (\.\d+)? above is grouping for the optional patch component, and it changes nothing about what is extracted. Only (?P<version>...) extracts.
  • A match in which the named group does not participate is treated as a non-match, and the entry is dropped. A group behind a ? that did not fire yields no version, so there is no version to keep.
  • Every version string, extracted or verbatim, must be usable as a tag — the same rule name:version already obeys, so that a version can be referred to at all. Strings that are not are dropped. This is the other reason to reach for extraction: a tag like release/2.4 cannot be a version, and a pattern that lifts 2.4 out of it can.

latest_per: one row per release line

A long-lived project may have four hundred tags, and offering all of them is not a catalog. latest_per keeps only the newest of each line:

ValueFrom 0.12.0, 0.11.9, 0.11.8, 0.9.25, 0.9.24
none (default)all five
minor0.12.0, 0.11.9, 0.9.25
major0.12.0

Versions the semver parser cannot read — R2025a, 2024b — pass through unpruned. Pruning groups only what it can parse and never guesses a line for the rest, so a calendar-versioned project is not silently collapsed to one entry.

default_version

config.default_version names the exact version the app selects by default. Leave it out and the newest discovered version is the default, which is usually what you want for a package that tracks upstream continuously. Set it when the newest release is not the one you want people landing on — a maintenance line you still support, or a release you have actually tested against your install script.

The GitLab prerelease caveat

filter.prerelease: false is source-aware, and GitLab's notion of a prerelease is not the one you expect: GitLab marks only future-dated releases as upcoming. An rc or beta release that is already published is an ordinary release as far as gitlab_releases is concerned, and it will sail through prerelease: false into your catalog.

Guard it with a pattern instead. For GitLab projects, write the shape you accept rather than trusting the flag:

config:
  versions:
    source: gitlab_releases
    url: https://gitlab.com/acme/widget
    filter:
      pattern: '^v?(?P<version>\d+\.\d+\.\d+)$'

github_tags and gitlab_tags have no prerelease concept at all — a tag is a tag — so pattern is the only guard there too.

When not to declare discovery

Not every app should have a versions block. Leave it out when:

  • The software updates itself. A self-updating agent or toolchain that pulls its own releases has one meaningful version — whatever it is right now — and a catalog listing upstream tags for it is a lie the operator will act on.
  • Your app installs something with no upstream version, like the built-in apt app: what it installs is decided by its parameters, not by a version.
  • Versions are yours, not upstream's. Use list, or just concrete tags on the package.

An app with no versions block is not versionless: the catalog falls back to the tags of the app's own repository, so publishing myapp:2.1 offers version 2.1. Publish only myapp:default and there is one version — default — and APP_VERSION is unset for it.

Publish test builds where only you can see them

Because tags are versions, every tag you push is a version the catalog offers, including one you meant only for yourself. Push myapp:trybuild beside myapp:default and everyone who can see that app is offered trybuild in the version picker, with nothing to tell them it is not a release.

The scope you publish to is the control. An app reference is looked up in your project first, then your org, then the apps every org is offered, and the first repository that publishes the app answers — so a test build pushed into your own project shadows the shared package for you and for nobody else:

$ orc build -t acme/checkout/myapp:default --push   # only the acme/checkout project sees it
$ orc build -t acme/myapp:default --push            # the whole acme org sees it

Push a test build into your project, and move it up to the org only once it is something the rest of the org should land on.

End to end: packaging a CLI from GitHub releases

Here is the whole loop for a single-binary tool — jq — from an empty directory to a preview of what the catalog will offer. Its tags are jq-1.7.1, its release assets are per-platform binaries, and it publishes a sha256sum.txt alongside them. Check your own project's release page for its asset and checksum names; the shape below is what transfers.

1. The recipe. In an empty directory, write artifact.yaml:

artifactType: application/vnd.orc8r.app.v1
annotations:
  org.opencontainers.image.title: jq
  org.opencontainers.image.description: Command-line JSON processor
  org.opencontainers.image.source: https://github.com/jqlang/jq
config:
  versions:
    source: github_tags
    url: https://github.com/jqlang/jq
    filter:
      pattern: '^jq-(?P<version>\d+\.\d+(\.\d+)?)$'
      latest_per: minor
      limit: 20
files:
  - install-jq.sh
  - uninstall-jq.sh
platforms:
  - os: linux
    arch: amd64
  - os: linux
    arch: arm64

Two things are absent on purpose. There is no start phase: installing a CLI is work that finishes, and a phase that exits 0 is reported completed, not failed. And there are no install/uninstall commands, because the scripts are named by the convention — install-jq.sh and uninstall-jq.sh are found and run for those phases without being declared. (Watch for that if you were planning to pass arguments: a command you declare takes over from the convention-named script, which would then never run at all. Where the two architectures need genuinely different values, use per-platform vars rather than command arguments.)

2. The install script, install-jq.sh — fetch the exact version, verify, install versioned, expose:

#!/bin/sh
set -eu

# The recipe extracts 1.7.1 out of tag jq-1.7.1, so rebuild the tag here.
tag="jq-$APP_VERSION"
asset="jq-linux-$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')"
base="https://github.com/jqlang/jq/releases/download/$tag"
prefix="/opt/jq/$APP_VERSION"

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
cd "$work"

curl -fsSLO "$base/$asset"
curl -fsSL "$base/sha256sum.txt" > sums.txt

# No match, or a bad digest, exits non-zero and fails the phase.
grep " $asset\$" sums.txt | sha256sum -c -

# Claim only our own version's subtree; /opt/jq may predate us.
mkdir -p "$prefix/bin"
install -m 0755 "$asset" "$prefix/bin/jq"

# Expose on the default PATH. Repointing this is how a version switch lands.
ln -sfn "$prefix/bin/jq" /usr/local/bin/jq

"$prefix/bin/jq" --version

3. The uninstall script, uninstall-jq.sh — remove one version's tree, and the link only if it is still ours:

#!/bin/sh
set -eu

prefix="/opt/jq/$APP_VERSION"

target=$(readlink /usr/local/bin/jq 2>/dev/null || true)
case "$target" in
  "$prefix"/*) rm -f /usr/local/bin/jq ;;
esac

rm -rf "$prefix"
rmdir /opt/jq 2>/dev/null || true   # only when it is now empty

4. Build and publish. From the recipe's directory:

$ orc build -t jq:default --push
Resolved reference: orc8r.com/jq:default
sha256:2f9c…

orc build reads artifact.yaml from the directory you point it at (the current one by default), validates the recipe, embeds every file listed under files, and caches the result; --push publishes it. Publish under the tag default: that is the tag discovery reads its versions block from, and the one an app reference with no version resolves to. Discovered versions all share this package's scripts and parameter schema.

5. Preview what the catalog will offer.

$ orc versions jq
VERSION  DEFAULT  PLATFORMS
1.7.1    *        any
1.6      
1.5      

orc versions evaluates the same pipeline the server does, against the live upstream, and reports a source failure rather than quietly returning a short list. It reads the versions block from the published jq:default, so publish first and preview second — this is the loop you iterate on while getting a pattern right.

If the list is empty, work backwards through the pipeline: a misspelled source enumerates nothing; a pattern whose named group did not participate drops every entry; and versions that are not legal tags are dropped before they ever reach you.

6. Use it. The app now appears in the catalog with three versions. Attaching it to a pool at version 1.6 runs install-jq.sh on every node with APP_VERSION=1.6, which fetches tag jq-1.6, verifies it, installs it at /opt/jq/1.6/, and links it onto PATH.

  • Authoring apps — the rest of the package: parameters, lifecycle phases, endpoints, and publishing.
  • Pools — choosing an app version when you request nodes.
  • Projects — pinning a version so everyone in a project gets the same one.