Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 37m ago 32290114
source preview history blame raw 37.5 KiB

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 on trigger at all is an ordinary push ci and runs on every branch.
  • branches present: only matching branches trigger it.
  • tags present without branches: 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 neither branches nor tags runs 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 1 or yes would 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 in dist/ but not files in dist/sub/.
  • ** is rejected; use dist/* 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.
  • .git is 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 run would 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; env is per job and every step runs through /bin/sh -c in 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.
  • version must be 1.
  • 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 check and vole workflow run use the same validation as a push. A local run executes in the job’s image and ignores on.push and runs-on; it does apply the job’s network:, and refuses a bridge job 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