Skip to main content
Version: Next

dispat in CI

dispat is a release tool. You will nearly always run it on a CI runner. Choose an installation method based on where your pipeline runs:

You are onUse
A Node project with a lockfilenpm
GitHub Actionsthe composite action
GitLab CI, Jenkins, Buildkite, anything with a job imagea container image
anything else, or a custom imagethe install script

These methods install the same native CLI. The container images use the install script internally.

This page covers installing the binary on a runner. Read Pipeline patterns to learn what to run next. That guide covers everything from per-commit test windows to a fully gated release pipeline.

The GitHub Action​

- uses: yohimik/dispat@v1
- run: dispat --log-format json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The action installs the CLI and puts it on PATH. It does not run dispat. You run it in your own step, like you would with setup-go and setup-node. This lets you pass your own flags, environment and working directory.

Inputs​

InputDefaultMeaning
versionthe latest stable releaseVersion or tag to install: 1.2.3, v1.2.3 or services/dispat/v1.2.3.
bin-dir$HOME/.dispat/binWhere to install. The action adds it to PATH either way.
github-token${{ github.token }}dispat's own releases are public, where this only raises the API rate limit. A fork released from a private repository needs it to list the releases and to download the binary.

Pin a version to make a release reproducible a year from now:

- uses: yohimik/dispat@v1
with:
version: 1.2.3

Outputs​

The version output contains the installed version. The path output contains the binary's full path. Use the path when a later step needs an explicit location instead of relying on PATH:

- id: setup
uses: yohimik/dispat@v1
- run: echo "installed ${{ steps.setup.outputs.version }}"

The action supports ubuntu-*, macos-* and windows-* runners.

A full release job​

name: Release
on: workflow_dispatch

permissions:
contents: write # the release lock, tags, the release commit, releases

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # dispat reads tags and commit ranges; a shallow clone is refused (E196)
- uses: yohimik/dispat@v1
- run: dispat --log-format json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Set up your stage scripts in steps before this one. If your scripts shell out to node, go, cargo, or docker, you must install those tools first. dispat itself needs only git and a POSIX shell. A runner that needs several of them can install them with dispat as well: a list of pinned dispat install lines is a shell script, and it is safe to run on every boot. See Install manifests as shell scripts.

Grant contents: write even for a job that pushes nothing. The run claims the repository with a release lock tag on the remote before it plans. This refuses a second job instead of racing it. You can safely trigger the job on every merge because the second run stops immediately.

fetch-depth: 0 brings the tags with the history, which is the other half of what the job needs. A checkout made without them is refused with E196: the remote holds release records the checkout cannot see, and planning from them would publish a released version again. The run reads the remote's records under its lock and says which tag is missing; the remedy is git fetch --tags in the job, because dispat refuses rather than refreshing what it plans from.

Worker nodes in a pipeline​

A release that spreads its builds over worker nodes needs those nodes running while it runs. On a hosted CI service that is a second job: the workers start, the release job dispatches to them, and the workers end themselves when the work dries up.

name: Release
on: workflow_dispatch

permissions:
contents: write

jobs:
workers:
strategy:
matrix:
node: [build-a, build-b]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: yohimik/dispat@v1
- run: dispat worker --config .dispat/${{ matrix.node }}.yaml --idle-timeout 900
env:
DISPAT_EXECUTION_SECRET: ${{ secrets.DISPAT_EXECUTION_SECRET }}

release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: yohimik/dispat@v1
- run: dispat --log-format json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISPAT_EXECUTION_SECRET: ${{ secrets.DISPAT_EXECUTION_SECRET }}

Five details make this work:

  • Both jobs reach the repository being released and hold the same signing secret. The release job's execution object lists build-a and build-b by name alone, so their coordination branches go to the remote the release takes its lock on; each worker's file names itself and that repository as its endpoint. A job that creates its workers with names the committed file cannot know passes them as --worker name instead.
  • A hosted runner needs no inbound network. The transport is Git only: a worker polls the repository and pushes its answers back, so it serves from behind NAT with no port open and no address to reach it at.
  • A worker's Git credential writes coordination branches and nothing else. The worker jobs reach the repository with the credential their checkout persisted, and the host's branch and tag rules are what keep it from release branches, release tags and the lock tag. A job token is the same identity in every job of a workflow, so those rules cannot tell a worker job from the release job; give the workers a credential of their own, as a machine the job creates for itself does, when the rules have to.
  • The jobs start in parallel. The release job plans, then probes every configured worker within execution.timeouts.preflight, so raise that timeout to cover a runner starting up and pulling its image. A node that has not answered by then fails the release with E225, before anything is dispatched.
  • --idle-timeout ends the worker jobs. They exit 0 once the release is over, so the workflow finishes without a step that cancels them.

The workers need whatever their build commands call, and the secrets those commands read from the environment: a value written as $NPM_TOKEN in env travels as that reference and is expanded on the node. Keep publishing credentials off them unless a publish is deliberately delegated, and read the security section before one secret spans machines of different trust levels.

Worker nodes on Kubernetes is the same arrangement as an Indexed Job, for a cluster that adds and removes machines for the pool.

A machine the job creates for itself​

A job can also be its own pool: create a machine, start a worker on it, run, and delete the machine before the job ends. dispat's own release does this for its full suite. The job that runs dispat run tests --since all before a release creates one Compute Engine instance, starts dispat worker on it with a signing secret generated for that run alone, and names the machine on the command line by name alone:

dispat run tests --since all --worker "ci-worker-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"

The link states no endpoint, so the machine's coordination branches go to the repository itself: the job reaches it with the credential its checkout persisted, which the job's contents: write already covers. The worker needs a credential of its own, and it is not the job's GITHUB_TOKEN, which can push the release branch and its tags. The job mints an installation token of a GitHub App for the run with actions/create-github-app-token, once the machine exists so that the token's hour covers the suite, and the action revokes it when the job ends. The App is configured by a repository variable, CI_WORKER_APP_CLIENT_ID, and a secret, CI_WORKER_APP_PRIVATE_KEY; without them the suite runs on the runner alone and the job says so with a warning. The host's rules are what confine that token: give the App's identity the right to create and delete dispat-worker-* branches and keep it out of the rules' bypass list for the release branch, the release tags and the lock tag.

The script that does the rest is scripts/ci-worker.sh: it keeps an ephemeral ssh key pair, learns the instance's host keys from the cloud API rather than from the network, sends the signing secret, the token, the repository URL and the commit over ssh into files only the worker's user reads rather than through instance metadata, and starts the worker from a launcher that hands Git the token as an http.<url>.extraheader scoped to this repository's https URL, so the header is sent to no other URL of the host. It deletes the instance in an always() step, with a lifetime on the instance itself as the backstop for a job that never reaches that step, and then deletes any coordination branch of the run's node the sweep could not close, with the job's own credential. The machine shares the job's build cache: the script hands it the job's Actions cache credentials over ssh and gives it a docker-container builder, so a gate placed there replays the layers the runner cached instead of rebuilding them. The sweep's test profiles come back to the job through runOutputs, so the coverage gate that follows reads them where a local run would have left them.

A snapshot of the checkout travels through the repository, and a snapshot carries every untracked file Git does not ignore, so the job refuses to start the suite while git status shows one, and the credential file the Google Cloud authentication step writes into the workspace is ignored. Two risks remain, and both are accepted rather than closed. A task's commands inherit the worker's environment, so a test on the machine can read the token as it can already read the Actions cache credentials. And contents: write also lets the token edit GitHub Releases, which branch and tag rules do not cover.

What that release measures on every run, and what it asks of the account it runs as, is in the release workflow and infra/ci.tf: the identity needs to create, read and delete instances, and nothing else, because the instance carries no service account of its own.

Gating a pipeline on the plan​

A repository with nothing pending releases nothing and exits 0. This keeps your pipeline green when a merge only contains a docs: commit. But a stage whose point is that something shipped needs the opposite. A deploy, an announcement, or a downstream trigger will deploy nothing if it passes quietly on an empty plan.

Pass --require-release to gate these steps. The release and status commands exit 3 when the plan releases nothing. This gives you a dedicated code separate from exit 1 failures:

dispat status --require-release # 0 releasing, 3 nothing to release, 1 something is wrong

Only packages the run will actually publish count. A package held by Release-As: none gets no version this run. A package withheld until its providers release or excluded by your --package/--space/--group selection also counts as "not releasing".

The simplest pipeline uses one job that decides and one that acts:

jobs:
plan:
runs-on: ubuntu-latest
outputs:
releasing: ${{ steps.plan.outputs.releasing }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: yohimik/dispat@v1
- id: plan
run: |
rc=0
dispat status --require-release --log-format json || rc=$?
case "$rc" in
0) echo "releasing=true" >> "$GITHUB_OUTPUT" ;;
3) echo "releasing=false" >> "$GITHUB_OUTPUT" ;;
*) exit "$rc" ;;
esac

release:
needs: plan
if: needs.plan.outputs.releasing == 'true'
runs-on: ubuntu-latest
# ... the release job above

Run status as often as you like. It takes no lock and writes nothing. On release, dispat answers the flag before it takes the release lock and before beforeAll runs. This means dispat --require-release in a single-job pipeline refuses the run without claiming the repository or executing your scripts.

Capture and map the exit code in your shell script. Running it bare with set -e would end the job on 3 before you can read the answer. Use a case statement rather than an if statement. An if statement folds every nonzero code together, so a broken configuration would look like "nothing to release" and skip the job instead of failing. Exit 3 means nothing released, while everything else is a real failure. Use the bare form only when a step should fail the pipeline when nothing releases.

Gating a step on what changed​

Use dispat if --changed to gate one step inside a script. This uses the same selection dispat run covers packages by, while --require-release gates a whole job. You can build the docs in a PR pipeline only when the docs changed:

dispat if --changed -p docs --consumers --since origin/main --then 'dispat run build-docs'

The condition holds when changes reach the selection. It runs the --then script and passes the exit code through. It runs nothing and exits 0 when nothing relevant changed, keeping the step green. You need fetch-depth: 0 to count changes against a branch base.

File tests gate on artifacts the same way, with no repository involved. You can require a test report before deploying the docs:

dispat if -f packages/docs/data/report.json \
--then 'dispat run deploy-docs' \
--else 'echo "no test report; run the test job first" >&2; exit 1'

The container images​

Choose from four images on linux/amd64 and linux/arm64:

ImageBaseSize
yohimik/dispat-alpinealpine15 MB
yohimik/dispat-ubuntuubuntu59 MB
yohimik/dispat-debiandebian69 MB
yohimik/dispat-dinddocker:dind142 MB

Each image carries the CLI, git, CA certificates, an SSH client and tzdata. Add your pipeline's toolchain yourself. Every extra package in a base image is one more thing to patch.

# GitLab CI
release:
image: yohimik/dispat-alpine:1
script:
- dispat --log-format json
# Anywhere with a Docker daemon
docker run --rm -v "$PWD:/workspace" yohimik/dispat-alpine:1 dispat status

Tags​

TagExampleMoves
exact version1.4.2never
channelstable, rcevery release on that channel
major, major.minor1, 1.4stable releases only
lateststable releases only

A release publishes every tag from one build. The tags 1, 1.4 and latest share the exact digest of the version they follow.

Pin 1 in a pipeline. The latest tag never points at a prerelease. Use the channel tags for prereleases.

Two things worth knowing​

The repository you mount is owned by another uid. Every git call would fail with "dubious ownership" without configuration. The images set git config --system --add safe.directory '*' to make a bind-mounted checkout work.

Only dispat-dind declares an ENTRYPOINT. It inherits this from docker:dind to keep behaving like that image. You still need --privileged or a services: docker:dind companion. The other three images declare no entrypoint so GitLab CI and GitHub Actions can run scripts through the image's shell.

dispat self-update is not the way to upgrade one. That command replaces a binary that dies with the container. Change the image tag instead. The images set DISPAT_UPDATE_CHECK=0 to disable update warnings.

The install script​

The container images use this install script. It is the same script Getting started hands to a human:

With curl
curl -fsSL https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh
With wget
wget -qO- https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh

The script resolves the latest stable release or takes a --version flag. It downloads the binary for the current platform and verifies it against the GitHub checksum. Then it installs the binary into --bin-dir. The script requires only curl or wget, with no package manager or Go toolchain needed.

OptionDefaultMeaning
--versionlatest stable1.2.3, v1.2.3 or services/dispat/v1.2.3.
--bin-dir/usr/local/bin, else $HOME/.local/binWhere to install.
--os, --archthis machine'sOverride the platform, for building an image for another one.
--token$GITHUB_TOKENRaises the API rate limit for a public repository, and is what reads the releases and downloads the binary from a private one.

The script prints the resolved version to stdout and everything else to stderr. Capture the version in your own script:

VERSION=$(curl -fsSL https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh)

Pin the URL to a tag rather than main to make the installer reproducible. Use a URL like https://raw.githubusercontent.com/yohimik/dispat/services/dispat/v1.2.3/install.sh.

In your own image​

FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
ADD https://raw.githubusercontent.com/yohimik/dispat/main/install.sh /tmp/install.sh
RUN sh /tmp/install.sh --version 1.2.3 && rm /tmp/install.sh

The dispat images run this script in a throwaway first stage and copy the binary out. This keeps the downloader out of the published image. Read docker/README.md to see how this works.

The other tools a job needs​

Once dispat is on the runner, dispat install puts the rest of the job's tools there too. It reads any GitHub repository's releases, verifies the file against the size and checksum the release published, and installs it onto a folder on PATH, so a setup step needs no package manager and no second downloader:

- name: Install the tools the release needs
run: |
dispat install acme/deployer --asset 'deployer-{os}-{arch}' --release 2.1.0
dispat install cli/cli --asset 'gh_{version}_{os}_{arch}.tar.gz' \
--pipe 'tar -xz --strip-components=2 --wildcards "*/bin/gh"'

Pin the version with --release for a reproducible job, and put --check in front of it on a cached runner: it exits 1 only when the destination does not already hold that exact file, so a warm cache costs no transfer.

dispat itself uses Aqua for the tools shared by its workflows and build probes. .aqua/aqua.yaml records crier 1.1.1 and the yohimik/tinygo fork at 0.43.0-net.2, .aqua/registry.yaml describes their release assets, and .aqua/aqua-checksums.json records the GitHub release digests. .aqua/aqua-policy.yaml admits that local registry. scripts/install-tools.sh bootstraps the newest Aqua with dispat install, then accepts [all|crier|tinygo] [destination]. It writes the Aqua executable and the selected tools into the destination. Crier is a standalone binary; TinyGo remains a complete tinygo/ tree, linked from Aqua's cache so its lib and src directories stay beside bin/tinygo.

CI, Docker builds, and the spikes use the same recorded versions. The tool configuration README lists the update commands, including the required local registry policy. Choose the fork's prerelease explicitly, refresh the checksums, and review and commit both files together. Explicit selection prevents an unreleased draft tag from becoming a toolchain.

A private repository works too, with a token that may read its contents. dispat uses the same credential for the releases listing and for the download, taking the asset from its API endpoint rather than from the public URL. On GitHub Enterprise, name the variable with --token-env: the endpoint there is derived from the repository you typed rather than set by a flag, so GITHUB_TOKEN alone is deliberately not sent to it.

- name: Install a tool from a private repository
env:
GITHUB_TOKEN: ${{ secrets.TOOLS_READ_TOKEN }}
run: dispat install acme/internal-tool --asset 'tool-{os}-{arch}'

Git credentials on the runner​

dispat drives the git on the runner's PATH and hands it the environment the job already has. It does not set GIT_TERMINAL_PROMPT for you. A local run is a place where being asked for a password is the right behaviour: a developer standing at the terminal can answer, and answering no on their behalf would turn an answerable question into a failure.

A runner is the opposite case. Nobody is there to answer, so a fetch or a push against a remote whose credential is missing or expired waits for input that never arrives. dispat bounds its own waits; it does not bound git's prompt. Set the variable in the job:

env:
GIT_TERMINAL_PROMPT: 0

An unauthenticated remote then fails immediately and the job reports which credential is missing, instead of running until the pipeline's own timeout. If the runner image ships a graphical credential helper, set GIT_ASKPASS to an empty value as well, so the helper cannot answer the prompt with a dialog nobody will see.

Somewhere other than GitHub Actions​

Read The release job on other providers for full release jobs on GitLab CI, CircleCI, Jenkins, Buildkite and Azure Pipelines. That guide includes the clone settings and tokens each provider needs.

npm in Node pipelines​

Add @dispat/bin to the project's development dependencies and commit its lockfile. Set up a supported Node version (^20.17.0 || >=22.9.0), then install and run the locked tool:

npm ci
npm exec -- dispat status
# In the reviewed release job:
npm exec -- dispat --log-format json

The install step needs HTTPS access to GitHub release assets in addition to the npm registry. The package carries an exact native version and digest, so installation does not resolve latest or call the GitHub API. An npm-only patch can carry the same binary as its predecessor. Keep npm upgrades in the dependency-update workflow.

If CI disables installation scripts, or npm 12 has no project approval for this package, invoke the installer explicitly before running the CLI:

npm ci --ignore-scripts
npm explore @dispat/bin -- node build/bin/postinstall.js
npm exec -- dispat status

For pnpm, retain the workspace's script-approval policy or invoke the same packaged installer after installation. Do not enable every dependency's scripts just to repair this package. Keep registry credentials in the CI secret store; installing the public binary requires no GitHub token. See the npm monorepo example for build and publication stages.

When a dispat publish script manages npm dist-tags, map stable to latest and use the planned prerelease channel for prerelease versions. Fail the publish when a prerelease version has no prerelease channel instead of allowing it to become latest. Compare complete semantic versions before retaining or moving an existing tag; numeric prerelease identifiers use numeric order, so rc.10 follows rc.2.