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.
Workflow reference
This page lists every field Vole accepts in .vole/workflows/*.yaml, with its
defaults and limits. For a task-oriented introduction, see Workflows.
For .vole/images definitions, see Image reference.
Unknown keys are rejected. A workflow is validated when it is pushed and again by
vole workflow check, so a typo fails before a job is queued.
File location and limits
| Item | Value |
|---|---|
| Location | Directly under .vole/workflows/; subdirectories are rejected |
| Extension | .yaml or .yml |
| Maximum file size | 256 KiB |
| Schema version | 1 |
| Maximum jobs per workflow | 50 |
| Maximum steps per job | 100 |
| Maximum artifact declarations per job | 32 |
| Maximum artifact files per run | 256 |
| Maximum artifact bytes per run | 4 GiB |
| Maximum attempt log | 32 MiB (then truncated with a note) |
Maximum runs-on labels per job |
8 |
| Maximum step command size | 64 KiB |
| Maximum secret value | 128 KiB (NAME=value, the kernel’s environment limit) |
| Maximum secret name | 64 bytes |
| Maximum display name | 80 bytes |
| Maximum description | 500 bytes |
| Maximum path filters | 20 per list |
| Maximum accounts per event filter | 10 per list |
| Maximum mentions read from one body | 32 |
| Maximum schedules | 5 |
| Maximum manual inputs | 10 |
| Maximum event payload | 64 KiB |
| Default step timeout | 10 minutes, when the job has no budget |
| Maximum step timeout | 4 hours |
| Maximum job timeout | 4 hours |
Top-level fields
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
version |
integer | yes | — | Schema version. Must be 1. |
name |
string | no | the workflow file’s base name | Display name on the run page. |
description |
string | no | none | One-line summary of what the workflow is for. |
on |
mapping | no | — | Trigger configuration. |
on.push.branches |
list of strings | no | ["*"] for a workflow with no other trigger |
Branch patterns that trigger the workflow. |
on.push.tags |
list of strings | no | none | Tag patterns that trigger the workflow. |
on.push.paths |
list of strings | no | none | Path patterns a push must touch. |
on.push.paths-ignore |
list of strings | no | none | Path patterns a push must not change. |
on.change-request |
mapping or empty | no | none | Change-request actions that trigger the workflow. |
on.issue |
mapping or empty | no | none | Issue actions that trigger the workflow. |
on.comment |
mapping or empty | no | none | Comment actions that trigger the workflow. |
on.change-request.mentioned |
string or list | no | none | Body must mention one of these accounts. |
on.change-request.author |
mapping, string or list | no | none | Sender name allowlist and/or minimum role. |
on.issue.mentioned |
string or list | no | none | Body must mention one of these accounts. |
on.issue.assigned-to |
string or list | no | none | This event must assign one of these accounts. |
on.issue.author |
mapping, string or list | no | none | Sender name allowlist and/or minimum role. |
on.comment.mentioned |
string or list | no | none | Body must mention one of these accounts. |
on.comment.replied-to |
string or list | no | none | Parent comment must be by one of these accounts. |
on.comment.author |
mapping, string or list | no | none | Sender name allowlist and/or minimum role. |
on.schedule |
list of mappings | no | none | Cron schedules, in UTC. |
on.workflow-dispatch |
mapping or empty | no | none | A manual run, optionally with typed inputs. |
jobs |
mapping | yes | — | One or more jobs, keyed by job ID. |
version
The only supported value is 1:
version: 1
name
A display name for the workflow. The file path stays the identity — it is what a
run records and what vole workflow run selects — while name is what a reader
sees on a run page:
name: Verify main
When name is absent or empty, the workflow is shown by its file’s base name, so
.vole/workflows/verify.yaml is shown as verify.
The name is trimmed and must be one line of text, at most 80 bytes. It is display metadata; two workflows may share a name.
description
A one-line summary of what the workflow is for, shown under its name on each run page:
description: Build, test and lint every push to main.
The description is trimmed and must be one line of text, at most 500 bytes. It is optional; an absent description shows nothing.
on.push.branches
A list of branch patterns. The default depends on what else is present:
- A workflow with no
ontrigger at all is an ordinary push ci and runs on every branch. branchespresent: only matching branches trigger it.tagspresent withoutbranches: the workflow runs only on matching tags.- A workflow with only event triggers (
change_request,issue,comment,schedule,workflow_dispatch) does not run on a branch push. push:with neitherbranchesnortagsruns on every branch.
Patterns use path.Match semantics with one special case: a lone * matches every
branch, including names containing /. Other patterns keep the one-segment rule,
so release/* does not match release/1.0/hotfix.
Patterns may contain letters, digits, *, ?, [, ], !, ., _, / and
-. Empty patterns and patterns containing .. are rejected.
A leading ! excludes what an earlier pattern included. The last matching pattern
wins, so ["*", "!main"] runs everywhere except main:
on:
push:
branches: [main, "release/*"]
on:
push:
branches: ["*", "!main"]
An explicitly empty list means no branches trigger the workflow.
on.push.tags
A list of tag patterns. Tags have no default: a workflow runs on a tag only when
tags: is present and the tag matches.
on:
push:
tags: ["v*"]
Patterns follow the same rules as branch patterns, including ! exclusion. A
workflow with tags and no branches runs on tags only. A workflow with both runs
on matching branches and matching tags.
on.push.paths
A push runs only when at least one changed path matches one of these patterns.
Patterns are relative to the repository root. * stays inside one path segment;
** spans zero or more segments, so docs/** covers docs/ and everything under
it. At most 20 patterns may be declared.
on:
push:
paths: ["src/**", "cmd/**", "go.mod"]
An empty pattern, an absolute path, or a pattern containing .. is rejected. A
push that changes no files does not match a workflow with a path filter.
on.push.paths-ignore
Paths that do not trigger the workflow. An ignored path is removed before the allow list is consulted, so an ignored file cannot make a workflow run:
on:
push:
paths-ignore: ["docs/**", "**/*.md"]
paths and paths-ignore may be combined. When every changed path is ignored,
nothing runs. Both filters are evaluated against the range the push changed; a new
branch or tag has no previous tree, so every file counts as changed.
on.change-request
Runs when a change request changes. The run is checked out at the request’s base branch tip, not at the proposed head: an event run must not be a way to execute an unapproved contributor commit. The proposed head and the changed files are in the event payload.
on:
change-request:
types: [opened, updated, ready]
| Action | Meaning |
|---|---|
opened |
A request was created. |
updated |
Its head, base, title, description, or followed branch changed. |
ready |
The required approvals for the exact base and head were reached. |
closed |
It was closed without landing. |
merged |
It landed. |
The default is [opened, updated, ready]. An empty value means the default:
change-request: and change-request: {} are the same.
on.issue
Runs when an issue changes, at the default branch tip.
on:
issue:
types: [opened, closed]
Actions are opened, edited, assigned, closed, and reopened. The default is [opened].
An empty value means the default. assigned is queued when an edit adds an
assignee. An issue opened with an assignee already set queues opened, and its
assignees_added list makes that an assignment for assigned-to.
on.comment
Runs when a comment on an issue or a change request is created, edited, or deleted. The payload names the comment and the issue or change request it belongs to.
on:
comment:
types: [created]
Actions are created, edited, and deleted. The default is [created]. An
empty value means the default.
Event filters
on.change-request, on.issue, and on.comment accept filter fields beside
types. They select within the event’s action list; they do not change which
actions the trigger has to declare.
on:
issue:
types: [opened, assigned]
assigned-to: [coding-agent]
comment:
types: [created]
mentioned: [coding-agent]
replied-to: [coding-agent]
author:
name: [keenan]
role: maintain
| Field | Availability | Meaning |
|---|---|---|
mentioned |
change request, issue, comment | The event body names one of these accounts. |
replied-to |
comment | The comment answers a comment written by one of these accounts. |
assigned-to |
issue | This event added one of these accounts as an assignee, including assignees set when the issue was opened. |
author.name |
change request, issue, comment | The account that caused the event is one of these. |
author.role |
change request, issue, comment | The account that caused the event holds at least this repository role. |
The account filters (mentioned, replied-to, assigned-to, and author.name)
take one account or a list of accounts, at most 10. Names follow the account-name
rule: 2–32 characters, lowercase letters, digits, - and _. A leading @ is
accepted and removed. An explicitly empty list is rejected because a filter that
can never match has no visible symptom.
author may also be written as one account or a list, which is shorthand for
author.name:
author: keenan
author: [keenan, wilbur]
author.name, author.role, and assigned-to must match when present.
mentioned and replied-to are alternatives: a comment qualifies if it mentions
an account or answers an account, so a workflow that should run for either can
declare both. A comment is never treated as addressed to its own author, which
keeps an agent’s reply from waking itself.
author.role is the ACL filter. Its value is one role — read, contribute,
maintain, or admin — and it means “at least this role” for the effective
repository role of the account that caused the event. A maintain filter admits
maintainers and admins and refuses contributors and readers. none is rejected
because it would filter nothing. Use it when the rule is a capability rather than
a list of names:
on:
comment:
types: [created]
mentioned: [coding-agent]
author:
role: maintain
The role is resolved when the event is queued, from the repository’s access rules and the instance-wide role attached to them. Changing the rules later does not change a run that is already queued.
author.role is a policy gate on the recorded sender, not a substitute for
authentication. Contributors reach the metadata branch through the browser, which
records their session account, and writing that branch directly already requires
maintain. An above-maintain role is therefore not protection against a malicious
maintainer with direct push access.
The mention syntax is @name. Mentions inside inline code or a fenced code block
are ignored, as is the @ in an email address or a URL. A body contributes at
most 32 distinct mentions to the event.
A filter is evaluated when the event is queued. author names the person whose
write caused the event: the comment author, the issue author or editor, or the
maintainer who assigned the issue.
on.schedule
Cron schedules in UTC. A schedule queues the workflow at the default branch tip.
The field syntax is five fields — minute, hour, day of month, month, day of week —
with *, lists, ranges, and a step on * or a range. Names and @shorthands are
not accepted.
on:
schedule:
- cron: "0 3 * * 1-5"
At most 5 schedules may be declared. The run is queued once per matching minute; a server that was not running for a slot does not replay it later.
on.workflow-dispatch
Marks a workflow as runnable by hand. The CLI has vole workflow dispatch.
on:
workflow-dispatch:
inputs:
environment:
description: Where to deploy
type: choice
required: true
default: staging
options: [staging, production]
dry-run:
type: boolean
default: "true"
| Input field | Meaning |
|---|---|
description |
One line shown beside the field. |
type |
string, boolean, number, or choice. Default string. |
required |
The run is refused when the input has no value and no default. |
default |
Value used when the input is omitted. |
options |
The allowed values for choice. |
At most 10 inputs may be declared. A value must be one line and at most 4 KiB.
choice inputs need at least one option, and the default must be one of them.
A manual run is available to maintainers and instance admins, and the selected
branch must exist.
Event data available to steps
Every run has a trigger_kind (push, change_request, issue, comment,
schedule, or workflow_dispatch) and an action. A run started by a non-push
trigger also gets three variables:
| Variable | Value |
|---|---|
VOLE_EVENT |
The trigger kind, such as comment. |
VOLE_EVENT_ACTION |
The action, such as created. |
VOLE_EVENT_PATH |
Path to a JSON file, /run/vole/event.json. |
The JSON is pinned when the run is queued. It is data: read it with jq or a
program, never by interpolating it into a shell command. Common fields are
event, action, repository, ref, branch, commit, sender, and for a
change request base_sha, head_sha, and changed_files. The entity is under
issue, change_request, or comment; a manual run puts its resolved values
under inputs. An issue or change request carries mentions in its entity. An
issue also carries assignees_added and assignees_removed. A comment carries
mentions, reply_to_author, and reply_to. Long bodies are clipped at 16 KiB
and the payload marks that with body_truncated.
These variables are set by the runner and cannot be replaced by a workflow’s
env:, the same rule as CI and HOME.
jobs
A mapping of job IDs to job definitions. Job IDs must match:
^[a-z][a-z0-9_-]{0,31}$
That is 1–32 characters: lowercase letters, digits, _ and -, starting with a
letter. At least one job is required.
Job fields
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
name |
string | no | the job ID | Display name on the run page. |
description |
string | no | none | One-line summary of what the job does. |
image |
string | one of image / image-file |
— | Registry image the job runs in. |
image-file |
string | one of image / image-file |
— | Name of a definition under .vole/images/. |
runs-on |
string or list of strings | no | any runner | Runner labels the job requires. |
network |
none or bridge |
no | the runner’s setting | Whether the job’s container has a network. |
services |
mapping | no | none | Containers started beside the job, reached by name. |
secrets |
string or list of strings | no | none | Repository secrets the job reads, by name. Each name is also the variable a step finds it in. |
acts-as |
account name | no | none | A bot account the job acts as, with a credential for that run. |
env |
mapping | no | none | Environment every step of the job runs with. |
timeout |
duration string | no | none | Wall clock the job’s steps share. |
steps |
list | yes | — | Commands the job runs. At least one is required. |
artifacts |
list | no | none | Files the job publishes. |
pages |
mapping | no | none | Directory published as the repository’s website. |
Exactly one of image and image-file must be present. Declaring both, or
neither, is an error.
name
A display name for the job, shown on the run page in place of the job ID:
jobs:
test:
name: Unit tests
When name is absent or empty, the job is shown by its ID. The ID stays the key
that a run, a lease and vole workflow run --job use.
The name is trimmed and must be one line of text, at most 80 bytes.
description
An optional one-line summary of what the job does:
jobs:
test:
name: Unit tests
description: Runs the Go tests against the pinned toolchain.
The description is trimmed and must be one line of text, at most 500 bytes.
image
An OCI image reference used exactly as written:
jobs:
build:
image: golang:1.27-bookworm@sha256:...
The runner pulls and runs this image. Pin it by digest when reproducibility matters; a mutable tag can change between runs.
References are limited to 255 bytes and must start with a letter or digit. They may
contain letters, digits, ., _, /, :, @, +, ~ and -. A leading -,
.., ://, or a file: prefix is rejected. file: is reserved for
image-file:.
image-file
The name of a definition under .vole/images/:
jobs:
build:
image-file: go
image-file: go resolves .vole/images/go.yaml. The .yaml or .yml extension
is optional in the reference; image-file: go.yaml is also accepted.
Names are 1–64 characters: lowercase letters, digits, ., _ and -, starting
with a letter or digit. A definition must live directly under .vole/images/, not
in a subdirectory. Two files for the same name (go.yaml and go.yml) are
rejected as ambiguous.
The definition is validated and pinned when the run is queued. See Image reference for its fields and limits.
runs-on
A single label or a list of labels:
runs-on: linux
runs-on: [linux, arm64]
A runner must carry every listed label to receive the job. An absent or empty
runs-on means any runner may take the job. Duplicate labels are ignored; at most
8 labels may be declared. Each label must match:
^[a-z0-9][a-z0-9._-]{0,31}$
That is 1–32 characters: lowercase letters, digits, ., _ and -, starting
with a letter or digit.
See Runners § Start a runner for how a machine advertises labels.
network
Whether the job’s container can reach anything outside it:
jobs:
fetch:
image: golang:1.27-bookworm
network: bridge
steps:
- run: go mod download
bridge gives the container a network. none withholds it, and a step that tries
to reach out then fails to resolve a host, which is the answer a job that does not
need the network should get. Only these two values are accepted; there is no
host, because a job that shares the runner’s network is not isolated from it.
Both names are the modes every supported runtime takes for --network, so the same
workflow means the same thing on Docker, rootless Podman, and nerdctl. What the
network is underneath differs — Docker’s own bridge, Podman’s userspace stack,
nerdctl’s CNI bridge — and that is the machine’s business: the field says whether a
job may reach out, not how the runner provides the route.
The job decides, and the machine’s own setting fills in only when the field is absent:
| Job says | Runner can give bridge | Runner cannot |
|---|---|---|
| nothing | bridge, as the runner was started | none, as the runner was started |
none |
none | none |
bridge |
bridge | the job waits for a runner that can |
A job that asks for bridge is only leased to a runner started with
--container-network bridge, so it waits for a capable machine instead of running
without the network it asked for. A job that asks for none gets none from either
kind of runner, so the same workflow is isolated the same way everywhere.
A vole workflow run applies the same rule: the job’s request wins over the
command line, and a bridge job is refused when the local run was started with
--container-network none.
services
Containers started beside the job, for the servers a test or a build talks to:
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 ./...
A service is a container, not a job: it has no steps, no checkout, no secrets, and
no host mount, and it runs the image’s own command. 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 and two
services may listen on the same port.
A job with services must also declare network: bridge. Without it the service
could only be reached by giving the job a network the workflow did not ask for,
which is the promise network: none exists to keep. A push that declares services
with none or with no network: is refused. A bridge job is already only leased to
a runner that advertises bridge, so no new runner capability is involved.
The runner starts the services after the job image is resolved and before the first
step. A service that is not running when the steps are about to start fails the job.
There is no health check: a step that needs a server to accept connections waits for
it, as pg_isready does in a postgres test. When the attempt fails, times out, or is
canceled, the last 256 KiB of each service’s output is added to the log under
=== service db ===; a successful run does not carry it. The job stops first, then
the services, then the network. A runner killed mid-job leaves all three behind, and
its next start removes them by label.
| Constraint | Value |
|---|---|
| Maximum services per job | 3 |
| Service name | ^[a-z][a-z0-9-]{0,31}$, and the hostname a step uses |
| Image | A registry reference, not image-file:. Pin it by digest for reproducibility. |
| Environment | At most 64 entries; the same name, value, and one-line rules as a job’s env |
| Secrets | Not available to a service. A test credential is a literal in the workflow; a real one is for the job. |
| Isolation | The job’s user, user namespace, capabilities, and resource limits; /tmp is a writable tmpfs |
The service image must run as the job’s uid. A database image that expects to start
as root is not supported by this field; use an image that can run unprivileged, or
point its data directory at /tmp as the postgres example does. The resource flags
on vole runner apply to each container, so a job with services can use more than
one container’s limit.
env
Environment variables for every step of the job, on top of the image’s own. Later values win, so a job can change something its image set:
jobs:
test:
image: golang:1.27-bookworm
env:
GOCACHE: /cache/go-build
GOFLAGS: "-mod=readonly"
steps:
- run: go test ./...
| Constraint | Value |
|---|---|
| Maximum entries | 64 |
| Key syntax | ^[A-Za-z_][A-Za-z0-9_]*$ |
| Maximum value size | 8 KiB |
| Line endings | Values must be one line; CR/LF are rejected |
CI, VOLE_CI, GIT_TERMINAL_PROMPT, HOME and TMPDIR are set by the runner and
cannot be replaced: the sandbox depends on them, so the push that tries is refused
rather than silently disagreeing with it. VOLE_RUN_ID, VOLE_REPO,
VOLE_SSH_COMMAND and VOLE_KNOWN_HOSTS are reserved for the same reason: they point
a job at its credential, and a workflow that could rewrite them could redirect the
push.
This is how a job opts in to something the runner provides but does not decide for
it. The job cache is the one that matters today: the runner mounts a directory at
/cache and sets no variable for it, so pointing GOCACHE (or CARGO_HOME, or
npm_config_cache) there is what makes the cache shared. See
Runners § Caching.
secrets
The repository secrets this job reads. Each entry is a name, never a value: the instance supplies the values when the job is leased, and the name is also the environment variable a step finds it in. One name may be written on its own:
jobs:
deploy:
image: alpine:3.22
secrets: DEPLOY_KEY
steps:
- run: ./deploy.sh
or as a list:
secrets: [DEPLOY_KEY, REGISTRY_TOKEN]
Names are upper-case letters, digits and _, at most 64 bytes, starting with a
letter. A name that repeats another entry is refused, and so is one the runner sets
itself (CI, VOLE_CI, HOME, TMPDIR, GIT_TERMINAL_PROMPT, and the
VOLE_EVENT* variables).
A declared name the repository has no value for is not an error at push time and does not fail the job. The variable is left unset — a step can tell that apart from empty — and the runner writes one line to the log:
vole: DEPLOY_TOKEN is not set for this repository, so this job runs without it.
A step that requires one says so with the shell’s own check:
: "${DEPLOY_TOKEN:?DEPLOY_TOKEN is not set}".
A value is at most 128 KiB, the kernel’s limit for one NAME=value string in a
process environment, and it is not part of the workflow, of any container argument,
or of any run record. A record names the version of each value the job ran with.
The values reach the step through a file the runner writes into the run directory
mounted at /run/vole, and the runner sources it before each step. There is no
expressions layer: a value is a variable, and a step that builds a name at runtime
reads the file itself.
The runner replaces each delivered value with *** as the log is written, so a step
that prints one by accident does not put it in the log the instance stores. This is
best effort, and worth knowing exactly:
- a value that has been re-encoded — base64, hex, a URL escape, one field of a connection string — no longer matches, and is not masked;
- a value shorter than 4 bytes is not masked at all, because replacing every
occurrence of
1oryeswould rewrite the log and protect nothing; - an artifact, a site file or a release attachment is stored as the step wrote it, so a secret a step writes to a file it publishes is published.
acts-as
The bot account the job acts as:
jobs:
triage:
image-file: agent
acts-as: coding-agent
steps:
- run: vole issue comment "$ISSUE" --reply-to "$COMMENT" -F reply.md
The account must be a bot account (see Accounts and access § Bot accounts), and the instance issues the run a credential for it when the job is leased: a key generated for that run, written into the job, and gone when the run ends. The job inherits the account’s role, so nothing here grants more than that account may do.
Two things change, and only two:
- writing to the issues branch no longer requires
maintain. What a job may write there is what its account may do, asked the same way the browser asks: an account that may discuss files an issue and comments, an author manages the item it wrote, a maintainer triages any of them, and an account that may approve may approve. The credential adds no rule of its own; it removes the one requirement that made every metadata write a maintainer’s act; - the job can push to the instance without a long-lived key in a repository secret. The key is valid for this run, and a job whose account is retired or whose key has expired writes nothing.
The credential is a file, not a variable, and the runner points git at it, so a step does not need to know it exists:
| Variable | Value |
|---|---|
VOLE_RUN_ID |
The run this job is executing. |
VOLE_REPO |
The repository, as the instance names it. |
VOLE_SSH_COMMAND |
The ssh command that uses the run’s key and known_hosts file. |
VOLE_KNOWN_HOSTS |
The known_hosts file with this instance’s host key. |
These four are set by the runner and cannot be replaced by the workflow’s env:,
the same rule as CI and HOME. A job that names no acts-as gets none of them and no
credential, which is what every workflow had before this field existed.
timeout
A wall-clock budget for the job’s steps, instead of a cap on each step separately:
jobs:
test:
image: golang:1.27-bookworm
timeout: 2h
steps:
- run: go test ./...
- run: go vet ./...
The budget starts with the first step and is spent in order. A step that declares its
own timeout: is still cut short when the budget ends first, and a step that declares
none runs with whatever is left, so one long step is enough to say:
jobs:
agent:
image-file: agent
timeout: 4h
steps:
- run: run-the-session
When the budget is spent before a step starts, that step does not run and the job fails, with one line in the log saying so. The field is optional: without it each step is bounded by its own timeout, which is how a workflow worked before the field existed.
The image build is not part of the budget. It has its own cap, so a slow pull cannot eat the time the steps were given.
timeout is at most 4 hours, and a step whose own timeout is longer than the job’s is
refused at push time: the budget would end first, so the step could never use it.
steps
A non-empty list of shell steps, at most 100. Steps run in order. A step that exits non-zero stops the job and becomes the job’s exit code.
Steps run inside the job’s image, in the disposable checkout. They share the container and filesystem with the other steps in the same job.
Step fields
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
name |
string | no | first line of run |
Display name in the log. |
run |
string | yes | — | Shell command executed with /bin/sh -c. |
timeout |
duration string | no | the job’s remaining budget, or 10m |
Maximum time for this step. |
steps:
- name: test
run: go test ./...
timeout: 20m
run is trimmed and must not be empty. The maximum size is 64 KiB. name is
trimmed; when omitted it is derived from the first line of run, shortened to 60
characters.
timeout uses Go duration syntax (90s, 10m, 1h). It must be positive and no
more than 4 hours, and it may not be longer than the job’s own timeout when that is
declared. When a step times out, the job fails and its container is stopped.
A step that declares no timeout of its own runs with the rest of the job’s budget,
or 10 minutes when the job has no budget. See the job’s timeout, which is the budget the step runs inside.
artifacts
A list of file patterns to publish when the steps succeed. At most 32 declarations are allowed.
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
path |
glob string | yes | — | Files to publish, relative to the checkout. |
release |
boolean | no | false |
Also attach the matches to the tag’s release. |
artifacts:
- path: dist/*
- path: dist/checksums.txt
release: true
path rules:
- Must be relative to the checkout; absolute paths and
..segments are rejected. - Maximum 256 bytes.
- Must name files, not a directory: a trailing
/is rejected. - Uses one-segment glob semantics.
dist/*matches files directly indist/but not files indist/sub/. **is rejected; usedist/*or declare more patterns.- A pattern that matches nothing fails the job.
- Two matches with the same base name are refused because they would publish under the same download name.
.gitis never published; symlinks and non-regular files are skipped.
release: true requires the workflow to declare on.push.tags. The job must run
for an annotated tag; a branch run or a lightweight tag cannot publish a release.
Release artifacts are attached to the tag’s release in addition to being run
artifacts. See Releases.
pages
Publishes one directory as the repository’s website.
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
path |
directory string | yes | — | Directory to publish, relative to the checkout. |
pages:
path: site/
path must be relative, at most 256 bytes, and must not climb out of the
checkout with a .. path segment or contain glob characters (*, ?, [). A trailing / is accepted and trimmed. The directory
is published after the steps pass, using the same rules as
vole pages deploy.
Only one job in a workflow may declare pages. The target branch is set by the
instance (pages_ref), not by the workflow. The deploy is refused if the run is no
longer the newest commit on its branch. See Workflows § Publish a website.
Context and expressions
A workflow file is not a template. There is no ${{ }} and no substitution in a
step’s run: env values are literal, a step executes the shell it is given, and
everything the run knows arrives as an environment variable, a declared secret, or
the event file. A manual input is read from the event file like any other event
data:
on:
workflow-dispatch:
inputs:
environment:
type: choice
options: [staging, production]
jobs:
deploy:
image: alpine:3.22
steps:
- run: |
environment="$(jq -r '.inputs.environment' "$VOLE_EVENT_PATH")"
[ "$environment" = production ] || exit 0
./deploy.sh
jq is an example, not a dependency: the image is the dependency manifest, so
install the JSON reader the step uses.
This is a decision, not an unfinished feature:
- The shell is already the expression language. A second one would mean two ways to write the same comparison, with its own parser, type rules, functions, and escaping, all validated on every push.
- Event data is untrusted. A comment body, an issue title, or a manual input is
text someone else wrote; substituting it into
runwould turn data into code. A file keeps it data, and the shell’s quoting rules apply. - A secret is a value, not a name to build. It already arrives as an environment variable; an expression would invite it into a command line or a log line.
- The parts of a workflow that usually need expressions —
if,needs, matrix values, outputs — are not in the format. If they arrive, they can be typed fields with their own small, checkable rules instead of a general language.
What a workflow cannot express
The format deliberately supports push and event triggers with jobs of shell steps. It does not support:
- a job graph: there is no
needs, no job outputs, and no shared artifacts between jobs - a build matrix or per-step conditions (
strategy,if,continue-on-error) - step-scoped secrets, expressions for building a secret name, or a value written in the workflow file; secrets are job-scoped and the instance holds the values
- expressions or
${{ }}contexts; the shell is the expression language and the event file is the context. See Context and expressions. - cache keys, or artifact downloads between jobs
- concurrency groups, or cancel-in-progress
- workflow-level
env, working directories, or shells;envis per job and every step runs through/bin/sh -cin the checkout - reusable workflows or composite actions (
uses:); shell in an image is the reuse mechanism - environments, deployment approvals, per-job permissions, or OIDC
- event payloads larger than 64 KiB; a long body is clipped in the pinned event
Combined results for a commit under review are still produced automatically by the
server; on.change-request is the event trigger, not the combined-result scheduler.
A local vole workflow run ignores triggers and runs-on, publishes no artifacts,
deploys no site, and has no event payload. It applies a job’s network: request, so a
bridge job needs --container-network bridge here too, and it starts the job’s
services on the same kind of private network a runner would. It reads the secrets its
jobs declare from the instance the checkout’s remote names, and leaves the variables
unset — with a note — where it cannot.
Validation and pinning
- The whole file is validated before a push is accepted.
- Unknown keys are errors; use the field names on this page.
versionmust be1.- The commit, workflow blob, image definition, display names, descriptions, steps, environment, services, and artifact patterns are pinned when a run is queued. Later edits do not change that run.
vole workflow checkandvole workflow runuse the same validation as a push. A local run executes in the job’s image and ignoreson.pushandruns-on; it does apply the job’snetwork:, and refuses abridgejob when the run was started with--container-network none.
Complete example
version: 1
name: Verify and release
description: Test every matching push, then build a release from a version tag.
on:
push:
branches: [main, "release/*"]
tags: ["v*"]
jobs:
test:
name: Unit tests
description: Runs the test suite against the pinned toolchain.
runs-on: [linux, arm64]
image: golang:1.27-bookworm@sha256:...
env:
GOCACHE: /cache/go-build
GOMODCACHE: /cache/go-mod
steps:
- name: unit tests
run: go test ./...
timeout: 20m
artifacts:
- path: coverage/*
release:
name: Build release
runs-on: linux
image-file: release-toolchain
steps:
- name: build
run: make dist
artifacts:
- path: dist/*
release: true