Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Workflows
A workflow describes what CI runs: which pushes, change requests, issues, comments, schedules, or manual runs trigger it, which steps each job executes, and what it publishes. Machines and execution belong to runners; this page is about the repository side.
For every field, limit, and default, see the Workflow reference, including what a workflow cannot express.
Add a workflow
Workflows live in .vole/workflows/*.yaml. When you push a new workflow, Vole
checks it before accepting the push, so a broken workflow is rejected early.
version: 1
name: Verify main
description: Build, test and lint every push to main.
on:
push:
branches: [main]
jobs:
build:
name: Build and test
runs-on: [linux, arm64] # only a runner with both labels takes this job
image: golang:1.27-bookworm
steps:
- name: test
run: go test ./...
- name: build
run: make dist
artifacts:
- path: dist/*
pages:
path: site/ # publish this directory as the repository's site
name and description say what the workflow is for; the file path stays its
identity. When name is absent, a run shows the file’s base name (verify.yaml
becomes verify). A job’s name is shown in place of its ID on the run page.
These fields are display metadata: they do not change what runs, and two
workflows may share a name.
Every job must name the environment it runs in, with exactly one of image: or
image-file:. A workflow that names neither fails the push that introduced it.
See Images for the two forms.
Triggers work like this:
on.push.brancheslimits a workflow to certain branches; a leading!excludes what an earlier pattern included.on.push.tagslimits it to tag pushes.on.push.pathsandon.push.paths-ignorefilter a push by the files it changes.- A workflow with no
ontrigger at all runs on every branch. - A workflow that names
tags:runs only on tags. - A workflow that declares only event triggers does not also run on every push.
A push queues each job in the workflow as its own run. Jobs are independent and
unordered in CI: one job cannot wait for another, read its files, or consume its
result. The order in the file matters only to a local vole workflow run, which
executes the selected jobs in that order.
runs-on asks for a runner with every label in the list. A job with no runs-on
can run anywhere. See Runners § Start a runner for what the
labels mean on the machine.
A job can budget its whole run instead of each step separately. timeout: on a job
is the clock its steps share, so one long step is one number to write:
jobs:
test:
image: golang:1.27-bookworm
timeout: 2h
steps:
- run: go test ./...
A step with no timeout of its own runs with what is left of the budget, and a budget that runs out stops the job at the next step. Without the field, each step keeps its own cap and the job has no total. See Workflow reference § timeout, which also says why the image build is not part of the budget.
A job’s network is closed by default: the container gets whatever the runner can
offer, and a runner with no network is the normal case. network: bridge asks for
one, which a job that downloads dependencies needs, and only a runner that can
provide it takes the job. network: none withholds it, so a job that should not
reach out fails to resolve a host instead of quietly succeeding, even on a machine
that could have given it a network. Declaring the field makes the job behave the
same way everywhere instead of depending on which runner picked it up. See
Workflow reference § network.
When a push starts a job, Vole remembers the exact commit, the exact workflow file, and the exact image definition. Editing any of them later does not change a job that is already queued.
Triggers beyond a push
A workflow can react to a change request, an issue, a comment, a schedule, or a maintainer asking for a run:
version: 1
on:
change-request:
types: [opened, updated, ready]
issue:
types: [opened, closed]
comment:
types: [created]
schedule:
- cron: "0 3 * * 1-5"
workflow-dispatch:
inputs:
environment:
type: choice
default: staging
options: [staging, production]
jobs:
report:
image: alpine:3.22
steps:
- run: jq . "$VOLE_EVENT_PATH"
A change-request run checks out the request’s base branch tip, not the proposed head. The head and the changed files are in the event payload, so a workflow can report on a change without executing an unapproved contributor commit.
An issue, comment, or schedule run uses the default branch tip. A manual run uses
the branch chosen in the browser or with --ref.
The event is a JSON file. The runner sets VOLE_EVENT, VOLE_EVENT_ACTION, and
VOLE_EVENT_PATH (/run/vole/event.json) for every non-push trigger. Read it as
data; do not interpolate it into a shell command. See
Workflow reference § Event data available to steps
for the fields.
A workflow is not a template. There is no ${{ }} and no substitution inside
run: use env or a declared secret for a configuration value, and read the
event file when a step needs something the event carries. The event is text
someone else wrote, so it stays data instead of becoming part of a command. See
Workflow reference § Context and expressions
for the reasoning and an input example.
Filtering an event
A trigger can narrow an event to the cases a job cares about. This is how a coding agent can wait to be asked, instead of running on every comment:
version: 1
name: Coding agent
on:
issue:
types: [opened, assigned]
assigned-to: [coding-agent]
author:
role: maintain
comment:
types: [created]
mentioned: [coding-agent]
replied-to: [coding-agent]
author:
role: maintain
jobs:
agent:
image-file: agent
network: bridge
timeout: 2h
acts-as: coding-agent
steps:
- run: run-the-session
mentioned matches an @coding-agent in the issue, comment, or change body.
replied-to matches a comment that answers one written by coding-agent.
assigned-to matches an issue that was assigned to it, including one opened with
that assignment already set. author.role: maintain is the ACL filter: it admits
maintainers and admins, not contributors or readers. Use author.name instead when the
rule is a list of people rather than a capability.
A comment is not treated as addressed to its own author, so the agent’s reply does not start it again. Mentions inside code samples are ignored. See Workflow reference § Event filters for the exact rules.
The job acts as an account, so the account it uses should be a bot: it has no
browser sign-in and is shown as a bot wherever its name appears. acts-as: names it, and
the instance gives the job a credential for that account when it is leased — one
run’s key, written into the job, gone when the run ends — so a comment or a change it
writes is attributable and needs no long-lived key in a repository secret. The
account’s role still decides what it may do. See
Accounts and access § Bot accounts and
Workflow reference § acts-as.
network: bridge is there because the session calls a model; timeout because a
session can run longer than the ten-minute step default. A job that runs unattended
should have both.
A comment body is still untrusted text. If the job can write — comment, push a
branch, or open a change — use author.role: maintain or author.name to limit who
can start it. Assignment is not a substitute for that filter: whoever opened an
issue may assign it, so assigned-to alone lets a contributor whose issue it is
start the job. Combine the two when only a maintainer should be able to ask. Read
the agent’s instructions from the repository, never from the event body.
Manual runs
A workflow with on.workflow-dispatch can be started by hand. Maintainers and
instance admins can start one from a clone:
vole workflow dispatch --workflow .vole/workflows/deploy.yaml \
--ref main --input environment=production
The selected branch must exist, and each input is resolved against the types and defaults the workflow declares before anything is queued.
Combined results
A change request that cannot fast-forward needs a merge commit before it can land. Vole builds that merge and runs the jobs that apply to the base branch against it, so the tree that lands is the tree that was tested. The run is queued automatically when the head is pushed, and again when the base moves for a request that is under review or waiting to merge. It uses the workflow files at the merge, so a workflow change on the base applies to the landing, and it does not deploy the repository’s site.
A combined result derived from a contributor branch is untrusted code. It runs only after a maintainer allowed that exact head, and it uses the contributor’s cache namespace rather than the one a protected branch builds into.
Images
A job can name a registry image directly:
jobs:
build:
image: golang:1.27-bookworm@sha256:...
Or keep a reusable definition under .vole/images/ and reference it by name:
# .vole/images/go.yaml
version: 1
from: golang:1.27-bookworm
env:
CGO_ENABLED: "0"
run:
- apt-get update && apt-get install -y --no-install-recommends jq
jobs:
build:
image-file: go
steps:
- run: go test ./...
image-file: go resolves .vole/images/go.yaml. For the complete definition
schema, see the Image reference. A definition has:
| Field | Meaning |
|---|---|
version |
Required; currently 1 |
from |
Required base image reference |
env |
Optional environment variables for the image |
run |
Optional build commands; each becomes a RUN |
Definitions are validated when the workflow is pushed, including definitions that are not referenced, and they are pinned with the run. The runner builds the image locally and tags it by the definition digest, so later jobs with the same definition reuse the build. See Runners § Images for the execution, caching, and security details.
An image definition is code: its build runs on the runner through the OCI builder, so approving a contributor tip that changes a definition also approves that build. Prefer rootless Podman and pin the base image by digest.
The image is the dependency manifest. It should contain the toolchains and system
packages the builds need, plus /bin/sh and a POSIX sleep (Vole runs shell steps
and keeps the container alive between them). Pin the base image by digest; a
mutable tag changes what a build means.
Environment
env on a job applies to every step, after the image’s own, so the job wins:
jobs:
test:
image: golang:1.27-bookworm
env:
GOCACHE: /cache/go-build
steps:
- run: go test ./...
The runner sets CI=true, VOLE_CI=1, HOME=/tmp, TMPDIR=/tmp, and
GIT_TERMINAL_PROMPT=0; a workflow that tries to replace one of those is refused at
push time. Everything else is the workflow’s. This is how a job opts in to the
cache the runner mounts at /cache, and how a job using a registry image gets it
without an image definition.
Services
A job can start a database, a queue, or another server beside it and reach it by name. The job has to ask for a network too, because the service is reached over it:
jobs:
test:
image: golang:1.27-bookworm
network: bridge
services:
db:
image: postgres:17
env:
POSTGRES_PASSWORD: test
PGDATA: /tmp/pgdata
steps:
- run: |
DATABASE_URL=postgres://postgres:test@db:5432/postgres go test ./...
The service’s name is its hostname on a private network the runner creates for the
attempt. The job and its services are the only members, so db:5432 is the address
a step uses. The service is a container, not a job: it has no steps, no checkout,
no secrets, and no files from the runner. At most three may run beside one job.
The runner starts them after the image is ready and before the first step. A service
that exits before the steps start fails the job. There is no health check, so a step
that needs a server to accept connections waits for it, as a postgres test waits with
pg_isready. When the job fails, times out, or is canceled, the end of each
service’s output is added to the log under === service db ===; a passing run drops
it. The job, the services, and the network are removed together, and a killed
runner’s leftovers are cleaned up by its next start.
A service image must run as the job’s uid, and /tmp is writable. The postgres
example points PGDATA at /tmp for that reason. A database image that insists on
starting as root does not work here; use one that can run unprivileged or publish a
custom image. The runner’s memory, CPU, and PID flags apply to each container, so a
job with services can use more than one container’s share.
Secrets
A repository can keep the values its jobs need out of its code: a deploy key, a registry token, a staging password. They are stored encrypted on the instance and set by the people who maintain the repository:
vole secret set demo DEPLOY_TOKEN # the value is read from stdin
vole secret list demo
A job asks for one by name, and the name is also the variable a step reads:
jobs:
deploy:
image: alpine:3.22
secrets: [DEPLOY_TOKEN]
steps:
- run: ./deploy.sh # reads $DEPLOY_TOKEN
Declaring the name is what makes it present. A job that does not list a secret does not receive it, so a repository’s values are never ambient in its steps.
A name the repository has no value for does not fail the job: the variable is left unset, not empty, and the log says which one it went without.
vole: DEPLOY_TOKEN is not set for this repository, so this job runs without it.
That is what makes an optional secret optional. When one is required, the step says so, and the reader gets the message its author wrote:
steps:
- run: |
: "${DEPLOY_TOKEN:?DEPLOY_TOKEN is not set}"
./deploy.sh
A value is never copied into the workflow, into a container argument, or into a run record: the record names the version of each value the job ran with, so a result says which one it used. Values reach a step only as environment variables, and one is at most 128 KiB, which is as much as one environment variable can carry.
A value is also replaced with *** in the log as it is written, including one a
step prints by accident. That is best effort: a re-encoded value is not caught, a
value under four bytes is left alone, and an artifact or a site file is published
exactly as the step wrote it. See
Workflow reference § secrets.
Anybody who can run a runner for a repository can read the values its jobs declare, because that is the machine they arrive on. Secrets belong to the accounts that approve work and allow CI, and the Secrets tab of the settings page that lists them never shows one. See Accounts and access.
Contributor branches
A push to refs/heads/contrib/<name>/... queues no work by itself. Those
branches contain code from outside the repository’s maintainer group, and running
a workflow executes it on a runner. CI is held until a maintainer or admin opens the
change request for the branch and chooses Allow CI there.
The approval is keyed by the exact commit, not by the branch. A new commit or a force-push is a new decision, so the contributor cannot push once and inherit approval forever. The approval is stored in the CI queue database, so a reconciliation or a temporary queue outage does not silently run an unapproved tip.
Allowing CI queues every workflow that applies to the branch. Workflows with no
on.push.branches filter run on every branch, including names with slashes such
as contributor branches.
Check and run a workflow locally
You can check workflows before pushing:
vole workflow check
vole workflow list
You can also run the steps without a server or a runner. From the repository root:
vole workflow run # every job in every workflow
vole workflow run build # every job in build.yaml
vole workflow run build -j test # one job in one workflow
vole workflow run -j test # the job named test in every workflow
A workflow can be named with or without its extension (verify or verify.yaml). The
command validates the workflow first, with the same checker a push uses, then
stops at the first failure and exits with that step’s status, so it can wrap a
script.
The steps run in the image the job names, not on your machine. That is the point:
the answer here is the answer CI gives, so a toolchain difference between “works
for me” and a red run does not exist. An image-file: definition is built and
cached by digest exactly as a runner would build it, and the working tree is bound
into the container, so the steps see your uncommitted edits and leave their output
where you would expect.
The run needs a container runtime — Docker, rootless Podman, or nerdctl — and
--container-runtime selects one (Docker by default). There is no mode that skips
the container: a step that runs in a different environment proves nothing about
CI. The default network is bridge, because a job that cannot fetch a dependency
cannot run here either; --container-network none matches what a runner does by
default. The container runs as your uid and gid; its filesystem is writable but
thrown away when the run ends, and /tmp is a tmpfs.
A local run mounts the same job cache as a runner, under the user cache directory
($XDG_CACHE_HOME/vole/workflows, usually ~/.cache/vole/workflows), so a
repeated run is as fast here as it is on a runner. As on a runner, the run sets no
variables: the image’s env: (or the step) decides whether /cache is used. See
Runners § Caching. --cache-size caps it (5G by default) and
--no-cache empties it after every job.
A local run still ignores triggers and runs-on, publishes no artifacts,
and deploys no site. It does apply the job’s network: request, so a bridge job
needs --container-network bridge here too (the default), and it starts the job’s
services on the same kind of private network a runner would. It spends the job’s
timeout: the way a runner does. It sets CI=true and VOLE_CI=1, gives each step
whatever the job’s budget and its own timeout leave it, and reads no standard input.
It reads the secrets its jobs declare from the instance the checkout’s remote names,
with your own credential, so a job that deploys behaves the same here as it does on
a runner. Where it cannot — no remote, or no value stored — the variable is unset
and the note says which one. Jobs run in declaration order and share the working
tree, so a later job sees files an earlier one wrote; a pushed run queues each job
separately, starts it from a clean checkout, and has no ordering between jobs. A
local run is not an event, so it sets no VOLE_EVENT* variables and has no event
payload.
Artifacts
Use artifacts: to list files that a build should publish. Vole expands the
patterns in the checkout after the steps pass, then uploads the files to the same
storage used by LFS and CI artifacts.
artifacts:
- path: dist/*
Artifacts appear on the run page with a download link:
curl -OJ http://host/demo/runs/01M5.../artifacts/tool-linux
sha256sum tool-linux
A pattern that matches nothing fails the job. A build that stopped producing the declared file is exactly what the declaration is meant to catch. If two matches have the same base name, Vole refuses them so nothing is published under an unexpected name.
A run artifact belongs to its run. It is not a release artifact. Moving one into a
release is a separate step (vole release attach). Run artifacts are kept for as
long as the run exists.
Build a release
A tag push can build a release. Mark the artifacts release: true:
version: 1
on:
push:
tags: ['v*']
jobs:
release:
image: golang:1.27-bookworm
steps:
- name: build
run: make dist
artifacts:
- path: dist/*
release: true
git tag -a v1.0.0 -m "first release" # needs notes and a tagger
git push origin refs/tags/v1.0.0 # the runner builds and publishes
The release commit on the vole branch names the run that produced the files
(release: attach ... from run 01M5...), so git log vole still shows which build
shipped them. The run’s full record is in data/runs.db, not on the branch. A
lightweight tag is refused: it has no release notes and no tagger. A tag run never
counts as a branch’s required check, even if the tag points at that branch’s tip.
See Releases for the release page and artifact rules.
Publish a website
pages: publishes a directory as the repository’s website, using the same rules
as vole pages deploy: the branch contains exactly those files, so a page removed
from the build stops being served.
pages:
path: site/
The commit names the build (site: main from run 01M5... (6beaa53)), so
git log pages shows what put that tree there.
Vole refuses the deploy if the run is no longer the newest commit on its branch. An older build finishing late must not silently roll the site back. The run log explains why:
this run built f1f415ec but main is at 6beaa530 now; the site was not deployed
Only one job per workflow can publish the site. The branch name is set by the
instance (pages_ref), not by the workflow.
What happens to a queued workflow
Pushing again cancels queued runs for the commit it replaced. It never stops a run
that is already in progress; a maintainer can stop that one from its page or with
vole run cancel <id>, and the runner stops at its next heartbeat. The attempt that
comes back is kept as canceled.
A run can have more than one attempt. Each time a runner starts the job it records a new attempt, and a runner that disappears hands the job back for another one. After five attempts the run fails instead of cycling.
An attempt that never reaches the job’s first step ends as could not run, not
failed: the image could not be built, the container would not start, or the
network the job asked for could not be made. The run page shows the runner’s own
reason, and the check says the commit is not what failed, so a required check waits
instead of going red. It is a rerun away from finishing. A step that exits non-zero
is still a failure, and still reds the check, because that one is the code’s. A finished run can be queued again
with Rerun on its page or vole run rerun <id>; that is another attempt of the
same pinned workflow, image, and secrets, not a new run. A queued or running run
cannot be rerun. The run page lists each attempt’s log, and the log URL takes
?attempt=N.
A run’s status is queued, running, success, failed, canceled, or error,
which the page calls could not run. Only the last two are not a verdict about the
commit in the same way: a canceled run was stopped on purpose, and an error run
never started.
Finished runs are stored in data/runs.db, not on the repository’s vole branch.
For what happens when a runner disappears, how work is requeued, and how finished
history is rebuilt, see Runners § Scheduling and the queue.