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 on | Use |
|---|---|
| A Node project with a lockfile | npm |
| GitHub Actions | the composite action |
| GitLab CI, Jenkins, Buildkite, anything with a job image | a container image |
| anything else, or a custom image | the 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
| Input | Default | Meaning |
|---|---|---|
version | the latest stable release | Version or tag to install: 1.2.3, v1.2.3 or services/dispat/v1.2.3. |
bin-dir | $HOME/.dispat/bin | Where 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
executionobject listsbuild-aandbuild-bby 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 nameinstead. - 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 withE225, before anything is dispatched. --idle-timeoutends the worker jobs. They exit0once 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:
| Image | Base | Size |
|---|---|---|
yohimik/dispat-alpine | alpine | 15 MB |
yohimik/dispat-ubuntu | ubuntu | 59 MB |
yohimik/dispat-debian | debian | 69 MB |
yohimik/dispat-dind | docker:dind | 142 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
| Tag | Example | Moves |
|---|---|---|
| exact version | 1.4.2 | never |
| channel | stable, rc | every release on that channel |
| major, major.minor | 1, 1.4 | stable releases only |
latest | stable 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:
curl -fsSL https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh
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.
| Option | Default | Meaning |
|---|---|---|
--version | latest stable | 1.2.3, v1.2.3 or services/dispat/v1.2.3. |
--bin-dir | /usr/local/bin, else $HOME/.local/bin | Where to install. |
--os, --arch | this machine's | Override the platform, for building an image for another one. |
--token | $GITHUB_TOKEN | Raises 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.