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/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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
source preview history blame raw 23.8 KiB

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.branches limits a workflow to certain branches; a leading ! excludes what an earlier pattern included.
  • on.push.tags limits it to tag pushes.
  • on.push.paths and on.push.paths-ignore filter a push by the files it changes.
  • A workflow with no on trigger 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.