Stupidly Light CI runner for
soft-serve and local machines.
Single binary. No database. No API keys. Pipeline defined as .pipe.yml (or
.pipe/*.yml) in each repository.
$ pipe run # Run pipeline in current project
$ pipe server # Receive pushes from soft-serve and run pipelinespipe is now container-first:
- runtime priority: Docker socket, then Podman socket (rootful/rootless), then local CLI context
- local runs use an isolated temp workspace by default (
--isolate=true) - host execution still exists for compatibility but is deprecated
--socketis optional; without it,docker/podmandefault context is used
You can force behavior:
pipe run --executor container --engine podman
pipe run --executor hostAdd .pipe.yml to any repository:
name: my-app
image: docker.io/library/golang:1.26-bookworm
steps:
- name: test
run: go test ./...
- name: build
run: go build -o dist/app .
- name: deploy
run: scp dist/app server:/usr/local/bin/app
branches:
- mainrun it locally:
$ cd my-app
$ pipe runimage at top-level applies to all steps. step.image overrides per step.
Server/global --image is only a fallback when pipeline/step images are absent.
name: polyglot
image: docker.io/library/golang:1.26-bookworm
steps:
- name: go-test
run: go test ./...
- name: rust-check
image: docker.io/library/rust:1-bookworm
run: cargo checkSome images do not add toolchain binaries to PATH by default. When needed, set
env.PATH explicitly in the pipeline, or use env."PATH+" to prepend entries
without replacing the runtime default. pipe also hardens overridden PATH
values by appending standard system directories (/usr/bin, /bin, etc.) when
they were omitted.
Example:
env:
"PATH+": /usr/local/go/binPull behavior is configurable with --pull-policy:
missing(default): pull only when the image is absent locallyalways: refresh image before usenever: never pull (run fails if the image is not already cached)
Steps marked parallel: true that appear consecutively are grouped and executed
concurrently — but only when runtime.NumCPU() > 1. On a single-core host
(e.g. a Nanode), every step runs sequentially regardless of the flag.
Output from each parallel step is buffered and flushed in declaration order once the group finishes, so logs are never interleaved.
steps:
- name: lint # ┐
run: golangci-lint run # ├── run in parallel on multi-core hosts
parallel: true # │
# │
- name: test # │
run: go test ./... # │
parallel: true # ┘
- name: build # sequential — waits for lint + test
run: go build .- name: deploy
run: ./scripts/deploy.sh
branches:
- main # only runs when branch == "main"When running locally with pipe run, no branch filtering is applied unless you
pass --branch:
pipe run --branch mainpipe now supports explicit DAG execution. Use either needs or
depends_on (alias):
steps:
- name: lint
run: deno lint
- name: test
run: deno test -A
needs: [lint]
- name: build
run: deno compile -A --output dist/app main.ts
depends_on: [test]Steps without needs/depends_on keep legacy order
(parallel: true groups + sequential groups). Steps with explicit dependencies
override that default for themselves.
Run sidecar containers (Postgres, Redis, etc.) for the whole pipeline:
services:
- name: postgres
image: docker.io/library/postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
steps:
- name: integration
run: go test -tags=integration ./...Services require container mode/runtime.
steps:
- name: test
run: go test ./...
failure: ignore # continue even if it fails
- name: notify-fail
run: ./scripts/notify.sh
runs_on: [failure] # success, failure, always
- name: cleanup
run: ./scripts/cleanup.sh
runs_on: [always]pipe injects the following variables into every step. In server mode these
reflect the push event. In local mode they reflect the current git state.
| Variable | Value |
|---|---|
PIPE_REPO |
Repository name |
PIPE_BRANCH |
Branch name when the run came from refs/heads/* |
PIPE_COMMIT |
Short commit SHA |
PIPE_REF |
Full git ref (e.g. refs/heads/main, refs/tags/v1) |
PIPE_TAG |
Tag name when the run came from refs/tags/* |
PIPE_PIPELINE |
Pipeline file used (e.g. .pipe/ci.yml) |
PIPE_ACTIONS_URL |
Base URL for shared actions (if configured) |
PIPE_EXECUTOR_MODE |
Effective executor mode (auto, container, host) |
PIPE_CONTAINER_ENGINE |
Container runtime selected (docker or podman) |
PIPE_CONTAINER_SOCKET |
Selected unix socket path (when available) |
PIPE_PULL_POLICY |
Effective pull policy (missing, always, never) |
Pipeline-level env: keys are also available, overridable by the above.
pipe supports secret injection and log masking:
pipe run --secret-env NAMEinjects host env varNAMEinto the run environmentpipe server --secret-env NAMEallowlists host env varNAMEso pipelines can request it viasecrets:- append
?to make it optional (--secret-env GITHUB_TOKEN?orsecrets: [GITHUB_TOKEN?]) - values from
--secret-envare redacted in stdout and log files - values of env vars that look sensitive (
*TOKEN*,*SECRET*,*PASSWORD*, etc.) are also redacted --mask VALUElets you redact additional literal values--no-mask-secretsdisables masking (not recommended)
Pipeline-level secrets (loaded from host env):
name: my-app
image: docker.io/library/golang:1.26-bookworm
secrets:
- GITHUB_TOKEN
- CR_PAT?CLI examples:
pipe run --secret-env GITHUB_TOKEN --secret-env CR_PAT
pipe run --secret-env GITHUB_TOKEN --secret-env CR_PAT?
pipe run --mask "https://token@github.com"# install to ~/.local/bin/pipe
make install-local
# optional: custom destination
make install-local INSTALL_PATH=/usr/local/bin/pipe
# verify
~/.local/bin/pipe versionIf ~/.local/bin is not in your PATH, add it:
export PATH="$HOME/.local/bin:$PATH"# Run all steps
pipe run
# Force containers (error if no runtime/image available)
pipe run --executor container
# Choose engine/socket explicitly
pipe run --engine docker --socket /var/run/docker.sock
pipe run --engine podman --socket /run/user/1000/podman/podman.sock
# Run in a specific directory
pipe run --dir /path/to/repo
# Run a single step by name
pipe run --step build
# Simulate a branch (enables branch-filtered steps)
pipe run --branch main
# Use a non-default pipeline file
pipe run --file .ci.ymlpipe run isolates the repository in a temporary workspace by default, so your
working tree stays clean.
# keep isolated workspace for debugging
pipe run --keep-workdir
# copy artifacts back to your repo after the run
pipe run --artifact dist/* --artifact coverage.out
# disable isolation (legacy behavior, not recommended)
pipe run --isolate=falseUse these from inside your project root:
# Go
cp /path/to/pipe/examples/go-project.pipe.yml .pipe.yml
pipe run --branch main
# Rust
cp /path/to/pipe/examples/rust-project.pipe.yml .pipe.yml
pipe run --branch main
# Deno
cp /path/to/pipe/examples/deno-project.pipe.yml .pipe.yml
pipe run --branch mainBefore running, adjust env: values in the copied file (binary name,
entrypoint, deploy host, etc.) to match your project.
If one repository needs different pipeline goals (fast CI, release, nightly),
keep them inside .pipe/ and select by name.
Suggested layout:
.pipe/
ci.yml
release.yml
nightly.yml
Optional pipeline-level ref filters:
name: release
tags:
- v*name: nightly
branches:
- nightly
- release/*When a pipeline-level branches: or tags: filter does not match, the server
marks that pipeline as ignored instead of failing the run.
This repository now follows that layout itself:
.pipe/
ci.yml
release.yml
Local runs:
# fast checks on every push
pipe run --pipeline ci --branch main
# release artifacts/signing
pipe run --pipeline release --branch main
# nightly maintenance/security
pipe run --pipeline nightly --branch mainFor pipe's own self-hosted release pipeline, the server should expose at least:
REGISTRY_PUSH_USERREGISTRY_PUSH_PASS
Optional attestation secrets:
COSIGN_PRIVATE_KEYCOSIGN_PASSWORDCOSIGN_PUBLIC_KEY
Tag pushes are expected to run .pipe/release.yml, which publishes
nest.urutau-ltd.org/pipe:<tag> and nest.urutau-ltd.org/pipe:latest.
Server mode uses a worker pool (--workers, default 1). Send
"pipeline":"ci" for one pipeline, or "pipelines":["ci","release"] to run
several in one push.
Server workspaces are also isolated per run. pipe keeps a per-repository git
cache under <workdir>/repos/<repo> and prepares each execution in its own
workspace under <workdir>/runs/<repo>/<run-id>. The shared cache is locked
during clone/fetch/prune/recovery, so concurrent runs never mutate the same
checkout. If the cache becomes stale after an upstream rebase or force-push,
pipe retries with remote pruning and falls back to re-cloning the cache.
pipe server # :9000, clone from http://soft-serve:23232
pipe server --port 8080
pipe server --clone ssh://git.example.com:23231
pipe server --workdir /var/lib/pipe
pipe server --executor auto --engine auto
pipe server --workers 2 --queue-size 128
pipe server --pull-policy missing
pipe server --label region=mx --label docker=true
pipe server --log-retention-days 14 --log-retention-count 2000
pipe server --log-retention-days 0 --log-retention-count 0 # disable pruning
pipe server --image docker.io/library/golang:1.26-bookworm
pipe server --log-format plain
pipe server --no-color
pipe server --secret-env GITHUB_TOKEN --secret-env CR_PAT
pipe server --actions-url "https://raw.githubusercontent.com/acme/pipe-actions/main"
pipe server --gotify-endpoint "https://gotify.local/message" --gotify-token "$GOTIFY_TOKEN"
pipe server --gotify-endpoint "https://gotify.local/message" --gotify-token "$GOTIFY_TOKEN" --gotify-on fail- request body is capped at
64KiB - branch and tag refs (
refs/heads/*,refs/tags/*) are accepted - server uses sane read/write timeout defaults
- startup preflight validates executor/runtime + pull policy
- periodic log pruning keeps disk usage bounded (
--log-retention-*)
Use labels to bind pipelines to specific runner instances.
Pipeline:
labels:
region: mx
docker: "true"Server:
pipe server --label region=mx --label docker=trueA pipeline is executed only when all required labels match.
| Method | Path | Description |
|---|---|---|
POST |
/run |
Trigger one or many pipeline runs |
GET |
/runs |
Inspect run state (queued, running, ok, fail) |
GET |
/runs/log |
Fetch a run log file (?id=<run-id>) |
GET |
/health |
Health check for Gatus / load balancers |
{
"repo": "my-app",
"ref": "refs/heads/main",
"old": "abc1234",
"new": "def5678",
"pipeline": "ci",
"pipelines": ["ci", "release"]
}Use either pipeline or pipelines (not both). If neither is sent, server uses
the default file configured with --file (default .pipe.yml).
POST /run responses now include runs=<id1,id2,...> so you can poll status:
curl -s "http://pipe:9000/runs?id=run-1713800000-1"
curl -s "http://pipe:9000/runs?limit=50"
curl -s "http://pipe:9000/runs/log?id=run-1713800000-1"Use the maintained hook from examples:
cp examples/soft-serve-post-receive.sh /opt/containers/soft-serve/hooks/post-receive
chmod +x /opt/containers/soft-serve/hooks/post-receiveUseful environment overrides (in soft-serve container/service):
PIPE_ENDPOINT(defaulthttp://pipe:9000)PIPELINES_MAIN(default["ci"])PIPELINES_NIGHTLY(default["nightly"])PIPELINES_DEFAULT(default["ci"])PIPELINES_TAG(default["release"])PIPE_WAIT=1to poll/runsuntil done and print status/log URL
When --gotify-endpoint is set, pipe emits notifications with status, detail,
pipeline, commit, run id, and log file:
--gotify-token <token>: app token sent asX-Gotify-Key--gotify-on all(default): notify success and failure--gotify-on fail: notify only failed runs--gotify-priority <n>: set Gotify message priority
pipe:
image: "ghcr.io/urutau-ltd/pipe:latest"
container_name: pipe
restart: always
environment:
- PIPE_WORKDIR=/opt/containers/pipe/workdir
- GITHUB_TOKEN=${GITHUB_TOKEN}
- CR_PAT=${CR_PAT}
ports:
- "127.0.0.1:9000:9000"
volumes:
- "/opt/containers/pipe/workdir:/opt/containers/pipe/workdir:Z"
- "/var/run/docker.sock:/var/run/docker.sock"
command:
- "server"
- "--clone"
- "http://soft-serve:23232"
- "--workdir"
- "/opt/containers/pipe/workdir"
- "--executor"
- "container"
- "--engine"
- "docker"
- "--socket"
- "/var/run/docker.sock"
- "--workers"
- "2"
- "--queue-size"
- "128"
- "--pull-policy"
- "missing"
- "--log-retention-days"
- "14"
- "--log-retention-count"
- "2000"
- "--image"
- "docker.io/library/golang:1.26-bookworm"
- "--log-format"
- "plain"
- "--secret-env"
- "GITHUB_TOKEN"
- "--secret-env"
- "CR_PAT"
- "--actions-url"
- "${PIPE_ACTIONS_URL:-}"
- "--gotify-endpoint"
- "${PIPE_GOTIFY_ENDPOINT}"
- "--gotify-token"
- "${PIPE_GOTIFY_TOKEN}"
- "--gotify-priority"
- "5"
- "--gotify-on"
- "all"When pipe runs inside a container and talks to host Docker via
/var/run/docker.sock, the pipeline workspace path must be identical in both
namespaces. In other words: if --workdir is /opt/containers/pipe/workdir,
mount host:/opt/containers/pipe/workdir to
container:/opt/containers/pipe/workdir (same absolute path on both sides).
For rootless Podman, use your user socket instead (for example
/run/user/1000/podman/podman.sock) and use
--engine podman --socket <that-path>.
In server mode, each run writes a log file to
<workdir>/logs/<repo>-<pipeline>-<timestamp>-<index>.log. All output is also
streamed to stdout, visible in Dozzle.
Log rendering modes:
--log-format auto(default): pretty box in TTY, line-friendly stream in non-interactive outputs (Dozzle-friendly)--log-format pretty: force decorated terminal-style output--log-format plain: force stream layout--no-color: disable ANSI color codes (format remains the same)
You can reuse actions without derived images or bind mounts.
Please do not abuse this mechanism on Git instances that are not yours!
Host executable scripts in a repo (for example go/test.sh,
release/publish.sh) and expose raw files.
Start server with a base URL:
pipe server --actions-url "https://raw.githubusercontent.com/acme/pipe-actions/main"
# or Codeberg raw endpoint
# pipe server --actions-url "https://codeberg.org/acme/pipe-actions/raw/branch/main"Use those scripts in pipelines:
steps:
- name: test
run: pipe_action go/test.sh
- name: release
branches: [main]
run: pipe_action release/publish.sh v1.2.3pipe_action <path> [args...] downloads ${PIPE_ACTIONS_URL}/<path> with
curl -fsSL and executes it.
Security baseline:
- pin immutable URLs when possible (commit SHA/tag, not moving branches)
- keep the action repo private/internal when appropriate
- treat actions like code dependencies (review and version them)
pipe now enforces semver tags and blocks accidental v1.* tags in release
workflows. Use the v2.x.y line for point releases (for example, if v1.1.5
was an accidental tag for v2 code, release the correction as a v2.x tag).
For teams that prefer zero remote scripts, keep reusable shell blocks inside YAML anchors and copy between repos.
x-go-vet-run: &go_vet_run |
go vet ./...
x-go-test-run: &go_test_run |
go test ./...
steps:
- name: go-vet
run: *go_vet_run
- name: go-test
run: *go_test_runUse anchors for policy blocks and reusable action calls.
x-quality: &quality
parallel: true
branches: [main, develop]
x-release-only: &release_only
branches: [main, release]
x-go-vet-action: &go_vet_action pipe_action go/vet.sh
x-go-test-action: &go_test_action pipe_action go/test.sh
steps:
- name: go-vet
<<: *quality
run: *go_vet_action
- name: go-test
<<: *quality
run: *go_test_action
- name: release-upload
<<: *release_only
run: pipe_action release/upload.shSee the examples/ directory:
| File | Demonstrates |
|---|---|
advanced-ci.pipe.yml |
DAG (needs), services, labels, and failure hooks |
soft-serve-post-receive.sh |
Hardened soft-serve hook with branch mapping + run polling |
go-project.pipe.yml |
Parallel lint + test, build, deploy |
rust-project.pipe.yml |
cargo fmt + clippy, test, release build, deploy |
deno-project.pipe.yml |
deno fmt + lint, test, compile, deploy |
multi-ci.pipe.yml |
Fast CI pipeline for frequent pushes |
multi-release.pipe.yml |
Release artifacts, checksums, signing |
multi-nightly.pipe.yml |
Nightly maintenance/security checks |
monorepo.pipe.yml |
Multi-service monorepo checks and packaging |
gpg-sign.pipe.yml |
GPG-signing a release binary |
attest.pipe.yml |
SLSA provenance + cosign attestation |
artifacts.pipe.yml |
Collecting, hashing, and publishing build artifacts |
pipe itself uses pipe to test. Look:
$ CC=gcc make demo-local
go run . run --branch main
╔══ pipe: pipe ══╗ (parallel ok cpus=28)
[pipe] executor=auto engine=podman socket=/run/user/1000/podman/podman.sock
[11:15:36] ⇉ vet
go: downloading gopkg.in/yaml.v2 v2.4.0
go: downloading codeberg.org/urutau-ltd/aile/v2 v2.1.1
✓ vet (9.818s)
[11:15:36] ⇉ test
go: downloading codeberg.org/urutau-ltd/aile/v2 v2.1.1
go: downloading gopkg.in/yaml.v2 v2.4.0
ok github.com/urutau-ltd/pipe 1.031s
✓ test (23.752s)
[11:15:59] ▶ build
go: downloading gopkg.in/yaml.v2 v2.4.0
go: downloading codeberg.org/urutau-ltd/aile/v2 v2.1.1
==> pipe e39f52c
-rwxr-xr-x 1 root root 7.1M Apr 21 17:16 dist/pipe
✓ build (9.592s)
────────────────────────────────
PASSED passed=3 failed=0 skipped=0 time=43.162s