Hosted runners

Jobs run on Pipemesh's hosted runners unless they name one of your own. There's nothing to set up: a job with no runner: runs on the smallest arm64 size.

Pick a size

runner: vCPU Memory Minutes count
linux-arm64-small (default) or linux-amd64-small 2 6.5 GiB 1×
linux-arm64-medium or linux-amd64-medium 4 13 GiB 2×
linux-arm64-large or linux-amd64-large 8 26 GiB 4×
backend_test:
  job_type: build
  stage: checks
  runner: linux-arm64-medium   # two test JVMs side by side
  script: ./gradlew test

A job past its memory is stopped (out of memory), not its neighbors. CPU isn't capped: a small job gets about 2 vCPU on a full machine, and more on a quiet one. Each job's log ends with what it used:

[runner] linux-arm64-small: peak memory 3.4 GiB of 6.5 GiB, CPU time 131 s

arm64 or amd64

arm64 is the default, and most languages and tools run on it natively. Use linux-amd64-* for what only runs or builds on x86-64. Both architectures count the same.

  • Your image: needs a variant for the runner's architecture. Most official images have both, and so does Pipemesh's job image.
  • Builds target the runner's architecture unless they ask otherwise: docker build and publish: images, npm ci native modules, vercel build output. To ship amd64, use an amd64 runner or build for both.
  • Caches are shared by both architectures. If a job switches and caches native builds (node_modules, a compiled target/), put the architecture in the key: key: deps-amd64-${checksum:package-lock.json}.

Minutes and limits

Your organization is charged a hosted job's seconds, from when a runner takes it until it reports, weighted by its size. Docker doubles it. The month's total rounds up to minutes once, so a 20-second job costs 20 seconds. Your own runners aren't metered.

Each organization's limits:

  • At once: 20 vCPU, so 10 small jobs or 5 medium.
  • A month: 2,000 minutes (calendar month, UTC).
  • Sizes: small and medium on either architecture. Large on request.
  • A job: its timeout_seconds: (one hour when absent), 60 minutes at most. At the limit, the job fails.

A job over a limit is held, not failed. Its card reads queued · held with the reason, and it starts once its size fits or the month rolls over.

Queued jobs take turns by organization, then by pipeline, so no burst starves the rest. See usage and allowance in Settings → Organization → Compute.

The job image

With no image: or image_from:, a job runs in Pipemesh's job image: Ubuntu 24.04, like GitHub's runners, with git, curl, wget, jq, make, zip, Python 3.12, Node 24 LTS, the AWS CLI and Docker, and sudo. Scripts from GitHub Actions keep their sudo apt-get install, and pip install works outside a venv too. pipemesh/runner-images has its contents and releases. Jobs pick up a release from their next pod.

For other languages, use your own image:. It's used as-is and pulled without credentials, so it must be public, and it needs bash, git, curl and tar. An image_from: image is pulled with Pipemesh's own credentials.

Docker

dockerd: true gives a job a Docker daemon, for Testcontainers, docker compose or docker build. Jobs that publish: images get one too. These jobs run on your organization's own machines and count twice.

  • It starts in the background. The first docker command waits for it. A tool that uses the socket directly should run after docker info.
  • Without it, the docker CLI still does what needs no daemon (docker buildx imagetools, docker login). A command that needs one says so.
  • No service containers. No runner starts services:.

Good to know

  • One shell. before_script:, script: and after_script: run as one script, which stops at the first failing command. So after_script: doesn't run after a failure.
  • Lost jobs. A job pod that dies is reported at once. If a runner goes silent, its job fails after ten minutes. Your organization is charged up to the runner's last word.
  • Isolation. Jobs without Docker run unprivileged on shared machines, can't raise their privileges, and get the runtime's default system-call filter. Jobs with Docker run on their organization's own machines. All reach the internet (Pipemesh at its public address), never Pipemesh's own network.

Building for both architectures

publish: builds natively for its runner's architecture. For one tag with both platforms, run docker buildx in a job with dockerd: true. The default job image carries buildx.

Cross-compile when the toolchain can. A stage that runs on the build platform and compiles for the target needs no emulation: Go with GOARCH, a JVM jar, a JavaScript bundle. The other platform's stage only copies the result in.

FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app .

FROM alpine:3.22
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Emulate the rest. A RUN step in the other platform's stage (apt-get install, npm ci with native modules) fails with exec format error until the job registers QEMU. The job's Docker can run privileged containers, so that's one line. Emulated steps run several times slower.

The default builder makes one platform per build, so create your own:

image:
  job_type: build          # checks out the whole repository: the build context
  stage: build
  dockerd: true
  variables:
    IMAGE: ghcr.io/acme/app
  produces:
    app: oci
  script: |
    docker run --privileged --rm tonistiigi/binfmt --install amd64
    docker buildx create --use
    docker buildx build --platform linux/arm64,linux/amd64 --push \
      --metadata-file meta.json -t "$IMAGE:$CI_COMMIT_SHORT_SHA" .
    pipemesh produce oci app --ref "$IMAGE" \
      --digest "$(jq -r '."containerimage.digest"' meta.json)"

pipemesh produce checks that the digest resolves in the registry, then records it as the job's app entry. Other jobs consume it like a publish: image, and each platform pulls its own image from the tag. On an amd64 runner, install arm64 instead.