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 · 16m ago 4b8c9e1d
source preview history blame raw 23.8 KiB
1# Workflows
2
3A workflow describes what CI runs: which pushes, change requests, issues,
4comments, schedules, or manual runs trigger it, which steps each job executes, and
5what it publishes. Machines and execution belong to [runners](runner.md); this page
6is about the repository side.
7
8For every field, limit, and default, see the [Workflow reference](reference/workflow-reference.md),
9including [what a workflow cannot express](reference/workflow-reference.md#what-a-workflow-cannot-express).
10
11## Add a workflow
12
13Workflows live in `.vole/workflows/*.yaml`. When you push a new workflow, Vole
14checks it before accepting the push, so a broken workflow is rejected early.
15
16```yaml
17version: 1
18name: Verify main
19description: Build, test and lint every push to main.
20on:
21 push:
22 branches: [main]
23jobs:
24 build:
25 name: Build and test
26 runs-on: [linux, arm64] # only a runner with both labels takes this job
27 image: golang:1.27-bookworm
28 steps:
29 - name: test
30 run: go test ./...
31 - name: build
32 run: make dist
33 artifacts:
34 - path: dist/*
35 pages:
36 path: site/ # publish this directory as the repository's site
37```
38
39`name` and `description` say what the workflow is for; the file path stays its
40identity. When `name` is absent, a run shows the file's base name (`verify.yaml`
41becomes `verify`). A job's `name` is shown in place of its ID on the run page.
42These fields are display metadata: they do not change what runs, and two
43workflows may share a name.
44
45Every job must name the environment it runs in, with exactly one of `image:` or
46`image-file:`. A workflow that names neither fails the push that introduced it.
47See [Images](#images) for the two forms.
48
49Triggers work like this:
50
51- `on.push.branches` limits a workflow to certain branches; a leading `!` excludes
52 what an earlier pattern included.
53- `on.push.tags` limits it to tag pushes.
54- `on.push.paths` and `on.push.paths-ignore` filter a push by the files it changes.
55- A workflow with no `on` trigger at all runs on every branch.
56- A workflow that names `tags:` runs only on tags.
57- A workflow that declares only event triggers does not also run on every push.
58
59A push queues each job in the workflow as its own run. Jobs are independent and
60unordered in CI: one job cannot wait for another, read its files, or consume its
61result. The order in the file matters only to a local `vole workflow run`, which
62executes the selected jobs in that order.
63
64`runs-on` asks for a runner with every label in the list. A job with no `runs-on`
65can run anywhere. See [Runners § Start a runner](runner.md#start-a-runner) for what the
66labels mean on the machine.
67
68A job can budget its whole run instead of each step separately. `timeout:` on a job
69is the clock its steps share, so one long step is one number to write:
70
71```yaml
72jobs:
73 test:
74 image: golang:1.27-bookworm
75 timeout: 2h
76 steps:
77 - run: go test ./...
78```
79
80A step with no timeout of its own runs with what is left of the budget, and a budget
81that runs out stops the job at the next step. Without the field, each step keeps its
82own cap and the job has no total. See
83[Workflow reference § timeout](reference/workflow-reference.md#timeout), which
84also says why the image build is not part of the budget.
85
86A job's network is closed by default: the container gets whatever the runner can
87offer, and a runner with no network is the normal case. `network: bridge` asks for
88one, which a job that downloads dependencies needs, and only a runner that can
89provide it takes the job. `network: none` withholds it, so a job that should not
90reach out fails to resolve a host instead of quietly succeeding, even on a machine
91that could have given it a network. Declaring the field makes the job behave the
92same way everywhere instead of depending on which runner picked it up. See
93[Workflow reference § network](reference/workflow-reference.md#network).
94
95When a push starts a job, Vole remembers the exact commit, the exact workflow
96file, and the exact image definition. Editing any of them later does not change a
97job that is already queued.
98
99## Triggers beyond a push
100
101A workflow can react to a change request, an issue, a comment, a schedule, or a
102maintainer asking for a run:
103
104```yaml
105version: 1
106on:
107 change-request:
108 types: [opened, updated, ready]
109 issue:
110 types: [opened, closed]
111 comment:
112 types: [created]
113 schedule:
114 - cron: "0 3 * * 1-5"
115 workflow-dispatch:
116 inputs:
117 environment:
118 type: choice
119 default: staging
120 options: [staging, production]
121jobs:
122 report:
123 image: alpine:3.22
124 steps:
125 - run: jq . "$VOLE_EVENT_PATH"
126```
127
128A change-request run checks out the request's base branch tip, not the proposed
129head. The head and the changed files are in the event payload, so a workflow can
130report on a change without executing an unapproved contributor commit.
131
132An issue, comment, or schedule run uses the default branch tip. A manual run uses
133the branch chosen in the browser or with `--ref`.
134
135The event is a JSON file. The runner sets `VOLE_EVENT`, `VOLE_EVENT_ACTION`, and
136`VOLE_EVENT_PATH` (`/run/vole/event.json`) for every non-push trigger. Read it as
137data; do not interpolate it into a shell command. See
138[Workflow reference § Event data available to steps](reference/workflow-reference.md#event-data-available-to-steps)
139for the fields.
140
141A workflow is not a template. There is no `${{ }}` and no substitution inside
142`run`: use `env` or a declared secret for a configuration value, and read the
143event file when a step needs something the event carries. The event is text
144someone else wrote, so it stays data instead of becoming part of a command. See
145[Workflow reference § Context and expressions](reference/workflow-reference.md#context-and-expressions)
146for the reasoning and an input example.
147
148## Filtering an event
149
150A trigger can narrow an event to the cases a job cares about. This is how a coding
151agent can wait to be asked, instead of running on every comment:
152
153```yaml
154version: 1
155name: Coding agent
156on:
157 issue:
158 types: [opened, assigned]
159 assigned-to: [coding-agent]
160 author:
161 role: maintain
162 comment:
163 types: [created]
164 mentioned: [coding-agent]
165 replied-to: [coding-agent]
166 author:
167 role: maintain
168jobs:
169 agent:
170 image-file: agent
171 network: bridge
172 timeout: 2h
173 acts-as: coding-agent
174 steps:
175 - run: run-the-session
176```
177
178`mentioned` matches an `@coding-agent` in the issue, comment, or change body.
179`replied-to` matches a comment that answers one written by `coding-agent`.
180`assigned-to` matches an issue that was assigned to it, including one opened with
181that assignment already set. `author.role: maintain` is the ACL filter: it admits
182maintainers and admins, not contributors or readers. Use `author.name` instead when the
183rule is a list of people rather than a capability.
184
185A comment is not treated as addressed to its own author, so the agent's reply does
186not start it again. Mentions inside code samples are ignored. See
187[Workflow reference § Event filters](reference/workflow-reference.md#event-filters)
188for the exact rules.
189
190The job acts as an account, so the account it uses should be a bot: it has no
191browser sign-in and is shown as a bot wherever its name appears. `acts-as:` names it, and
192the instance gives the job a credential for that account when it is leased — one
193run's key, written into the job, gone when the run ends — so a comment or a change it
194writes is attributable and needs no long-lived key in a repository secret. The
195account's role still decides what it may do. See
196[Accounts and access § Bot accounts](accounts-and-access.md#bot-accounts) and
197[Workflow reference § acts-as](reference/workflow-reference.md#acts-as).
198
199`network: bridge` is there because the session calls a model; `timeout` because a
200session can run longer than the ten-minute step default. A job that runs unattended
201should have both.
202
203A comment body is still untrusted text. If the job can write — comment, push a
204branch, or open a change — use `author.role: maintain` or `author.name` to limit who
205can start it. Assignment is not a substitute for that filter: whoever opened an
206issue may assign it, so `assigned-to` alone lets a contributor whose issue it is
207start the job. Combine the two when only a maintainer should be able to ask. Read
208the agent's instructions from the repository, never from the event body.
209
210## Manual runs
211
212A workflow with `on.workflow-dispatch` can be started by hand. Maintainers and
213instance admins can start one from a clone:
214
215```sh
216vole workflow dispatch --workflow .vole/workflows/deploy.yaml \
217 --ref main --input environment=production
218```
219
220The selected branch must exist, and each input is resolved against the types and
221defaults the workflow declares before anything is queued.
222
223## Combined results
224
225A change request that cannot fast-forward needs a merge commit before it can land.
226Vole builds that merge and runs the jobs that apply to the base branch against it,
227so the tree that lands is the tree that was tested. The run is queued automatically
228when the head is pushed, and again when the base moves for a request that is under
229review or waiting to merge. It uses the workflow files at the merge, so a workflow
230change on the base applies to the landing, and it does not deploy the repository's
231site.
232
233A combined result derived from a contributor branch is untrusted code. It runs only
234after a maintainer allowed that exact head, and it uses the contributor's cache
235namespace rather than the one a protected branch builds into.
236
237## Images
238
239A job can name a registry image directly:
240
241```yaml
242jobs:
243 build:
244 image: golang:1.27-bookworm@sha256:...
245```
246
247Or keep a reusable definition under `.vole/images/` and reference it by name:
248
249```yaml
250# .vole/images/go.yaml
251version: 1
252from: golang:1.27-bookworm
253env:
254 CGO_ENABLED: "0"
255run:
256 - apt-get update && apt-get install -y --no-install-recommends jq
257```
258
259```yaml
260jobs:
261 build:
262 image-file: go
263 steps:
264 - run: go test ./...
265```
266
267`image-file: go` resolves `.vole/images/go.yaml`. For the complete definition
268schema, see the [Image reference](reference/image-reference.md). A definition has:
269
270| Field | Meaning |
271|---|---|
272| `version` | Required; currently `1` |
273| `from` | Required base image reference |
274| `env` | Optional environment variables for the image |
275| `run` | Optional build commands; each becomes a `RUN` |
276
277Definitions are validated when the workflow is pushed, including definitions
278that are not referenced, and they are pinned with the run. The runner builds the
279image locally and tags it by the definition digest, so later jobs with the same
280definition reuse the build. See [Runners § Images](runner.md#images-and-image-builds)
281for the execution, caching, and security details.
282
283An image definition is code: its build runs on the runner through the OCI
284builder, so approving a contributor tip that changes a definition also approves
285that build. Prefer rootless Podman and pin the base image by digest.
286
287The image is the dependency manifest. It should contain the toolchains and system
288packages the builds need, plus `/bin/sh` and a POSIX `sleep` (Vole runs shell steps
289and keeps the container alive between them). Pin the base image by digest; a
290mutable tag changes what a build means.
291
292## Environment
293
294`env` on a job applies to every step, after the image's own, so the job wins:
295
296```yaml
297jobs:
298 test:
299 image: golang:1.27-bookworm
300 env:
301 GOCACHE: /cache/go-build
302 steps:
303 - run: go test ./...
304```
305
306The runner sets `CI=true`, `VOLE_CI=1`, `HOME=/tmp`, `TMPDIR=/tmp`, and
307`GIT_TERMINAL_PROMPT=0`; a workflow that tries to replace one of those is refused at
308push time. Everything else is the workflow's. This is how a job opts in to the
309cache the runner mounts at `/cache`, and how a job using a registry image gets it
310without an image definition.
311
312## Services
313
314A job can start a database, a queue, or another server beside it and reach it by
315name. The job has to ask for a network too, because the service is reached over it:
316
317```yaml
318jobs:
319 test:
320 image: golang:1.27-bookworm
321 network: bridge
322 services:
323 db:
324 image: postgres:17
325 env:
326 POSTGRES_PASSWORD: test
327 PGDATA: /tmp/pgdata
328 steps:
329 - run: |
330 DATABASE_URL=postgres://postgres:test@db:5432/postgres go test ./...
331```
332
333The service's name is its hostname on a private network the runner creates for the
334attempt. The job and its services are the only members, so `db:5432` is the address
335a step uses. The service is a container, not a job: it has no steps, no checkout,
336no secrets, and no files from the runner. At most three may run beside one job.
337
338The runner starts them after the image is ready and before the first step. A service
339that exits before the steps start fails the job. There is no health check, so a step
340that needs a server to accept connections waits for it, as a postgres test waits with
341`pg_isready`. When the job fails, times out, or is canceled, the end of each
342service's output is added to the log under `=== service db ===`; a passing run drops
343it. The job, the services, and the network are removed together, and a killed
344runner's leftovers are cleaned up by its next start.
345
346A service image must run as the job's uid, and `/tmp` is writable. The postgres
347example points `PGDATA` at `/tmp` for that reason. A database image that insists on
348starting as root does not work here; use one that can run unprivileged or publish a
349custom image. The runner's memory, CPU, and PID flags apply to each container, so a
350job with services can use more than one container's share.
351
352## Secrets
353
354A repository can keep the values its jobs need out of its code: a deploy key, a
355registry token, a staging password. They are stored encrypted on the instance and
356set by the people who maintain the repository:
357
358```sh
359vole secret set demo DEPLOY_TOKEN # the value is read from stdin
360vole secret list demo
361```
362
363A job asks for one by name, and the name is also the variable a step reads:
364
365```yaml
366jobs:
367 deploy:
368 image: alpine:3.22
369 secrets: [DEPLOY_TOKEN]
370 steps:
371 - run: ./deploy.sh # reads $DEPLOY_TOKEN
372```
373
374Declaring the name is what makes it present. A job that does not list a secret
375does not receive it, so a repository's values are never ambient in its steps.
376
377A name the repository has no value for does not fail the job: the variable is left
378**unset**, not empty, and the log says which one it went without.
379
380```
381vole: DEPLOY_TOKEN is not set for this repository, so this job runs without it.
382```
383
384That is what makes an optional secret optional. When one is required, the step says
385so, and the reader gets the message its author wrote:
386
387```yaml
388 steps:
389 - run: |
390 : "${DEPLOY_TOKEN:?DEPLOY_TOKEN is not set}"
391 ./deploy.sh
392```
393
394A value is never copied into the workflow, into a container argument, or into a run
395record: the record names the version of each value the job ran with, so a result
396says which one it used. Values reach a step only as environment variables, and one
397is at most 128 KiB, which is as much as one environment variable can carry.
398
399A value is also replaced with `***` in the log as it is written, including one a
400step prints by accident. That is best effort: a re-encoded value is not caught, a
401value under four bytes is left alone, and an artifact or a site file is published
402exactly as the step wrote it. See
403[Workflow reference § secrets](reference/workflow-reference.md#secrets).
404
405Anybody who can run a runner for a repository can read the values its jobs declare,
406because that is the machine they arrive on. Secrets belong to the accounts that
407approve work and allow CI, and the Secrets tab of the settings page that lists
408them never shows one.
409See [Accounts and access](accounts-and-access.md#roles).
410
411## Contributor branches
412
413A push to `refs/heads/contrib/<name>/...` queues no work by itself. Those
414branches contain code from outside the repository's maintainer group, and running
415a workflow executes it on a runner. CI is held until a maintainer or admin opens the
416change request for the branch and chooses **Allow CI** there.
417
418The approval is keyed by the exact commit, not by the branch. A new commit or a
419force-push is a new decision, so the contributor cannot push once and inherit
420approval forever. The approval is stored in the CI queue database, so a
421reconciliation or a temporary queue outage does not silently run an unapproved
422tip.
423
424Allowing CI queues every workflow that applies to the branch. Workflows with no
425`on.push.branches` filter run on every branch, including names with slashes such
426as contributor branches.
427
428## Check and run a workflow locally
429
430You can check workflows before pushing:
431
432```sh
433vole workflow check
434vole workflow list
435```
436
437You can also run the steps without a server or a runner. From the repository root:
438
439```sh
440vole workflow run # every job in every workflow
441vole workflow run build # every job in build.yaml
442vole workflow run build -j test # one job in one workflow
443vole workflow run -j test # the job named test in every workflow
444```
445
446A workflow can be named with or without its extension (`verify` or `verify.yaml`). The
447command validates the workflow first, with the same checker a push uses, then
448stops at the first failure and exits with that step's status, so it can wrap a
449script.
450
451The steps run in the image the job names, not on your machine. That is the point:
452the answer here is the answer CI gives, so a toolchain difference between "works
453for me" and a red run does not exist. An `image-file:` definition is built and
454cached by digest exactly as a runner would build it, and the working tree is bound
455into the container, so the steps see your uncommitted edits and leave their output
456where you would expect.
457
458The run needs a container runtime — Docker, rootless Podman, or nerdctl — and
459`--container-runtime` selects one (Docker by default). There is no mode that skips
460the container: a step that runs in a different environment proves nothing about
461CI. The default network is `bridge`, because a job that cannot fetch a dependency
462cannot run here either; `--container-network none` matches what a runner does by
463default. The container runs as your uid and gid; its filesystem is writable but
464thrown away when the run ends, and `/tmp` is a tmpfs.
465
466A local run mounts the same job cache as a runner, under the user cache directory
467(`$XDG_CACHE_HOME/vole/workflows`, usually `~/.cache/vole/workflows`), so a
468repeated run is as fast here as it is on a runner. As on a runner, the run sets no
469variables: the image's `env:` (or the step) decides whether `/cache` is used. See
470[Runners § Caching](runner.md#caching). `--cache-size` caps it (5G by default) and
471`--no-cache` empties it after every job.
472
473A local run still ignores triggers and `runs-on`, publishes no artifacts,
474and deploys no site. It does apply the job's `network:` request, so a `bridge` job
475needs `--container-network bridge` here too (the default), and it starts the job's
476services on the same kind of private network a runner would. It spends the job's
477`timeout:` the way a runner does. It sets `CI=true` and `VOLE_CI=1`, gives each step
478whatever the job's budget and its own timeout leave it, and reads no standard input.
479It reads the secrets its jobs declare from the instance the checkout's remote names,
480with your own credential, so a job that deploys behaves the same here as it does on
481a runner. Where it cannot — no remote, or no value stored — the variable is unset
482and the note says which one. Jobs run in declaration order and share the working
483tree, so a later job sees files an earlier one wrote; a pushed run queues each job
484separately, starts it from a clean checkout, and has no ordering between jobs. A
485local run is not an event, so it sets no `VOLE_EVENT*` variables and has no event
486payload.
487
488## Artifacts
489
490Use `artifacts:` to list files that a build should publish. Vole expands the
491patterns in the checkout after the steps pass, then uploads the files to the same
492storage used by LFS and CI artifacts.
493
494```yaml
495artifacts:
496 - path: dist/*
497```
498
499Artifacts appear on the run page with a download link:
500
501```sh
502curl -OJ http://host/demo/runs/01M5.../artifacts/tool-linux
503sha256sum tool-linux
504```
505
506A pattern that matches nothing fails the job. A build that stopped producing the
507declared file is exactly what the declaration is meant to catch. If two matches
508have the same base name, Vole refuses them so nothing is published under an
509unexpected name.
510
511A run artifact belongs to its run. It is not a release artifact. Moving one into a
512release is a separate step (`vole release attach`). Run artifacts are kept for as
513long as the run exists.
514
515## Build a release
516
517A tag push can build a release. Mark the artifacts `release: true`:
518
519```yaml
520version: 1
521on:
522 push:
523 tags: ['v*']
524jobs:
525 release:
526 image: golang:1.27-bookworm
527 steps:
528 - name: build
529 run: make dist
530 artifacts:
531 - path: dist/*
532 release: true
533```
534
535```sh
536git tag -a v1.0.0 -m "first release" # needs notes and a tagger
537git push origin refs/tags/v1.0.0 # the runner builds and publishes
538```
539
540The release commit on the `vole` branch names the run that produced the files
541(`release: attach ... from run 01M5...`), so `git log vole` still shows which build
542shipped them. The run's full record is in `data/runs.db`, not on the branch. A
543lightweight tag is refused: it has no release notes and no tagger. A tag run never
544counts as a branch's required check, even if the tag points at that branch's tip.
545
546See [Releases](releases.md) for the release page and artifact rules.
547
548## Publish a website
549
550`pages:` publishes a directory as the repository's website, using the same rules
551as `vole pages deploy`: the branch contains exactly those files, so a page removed
552from the build stops being served.
553
554```yaml
555pages:
556 path: site/
557```
558
559The commit names the build (`site: main from run 01M5... (6beaa53)`), so
560`git log pages` shows what put that tree there.
561
562Vole refuses the deploy if the run is no longer the newest commit on its branch.
563An older build finishing late must not silently roll the site back. The run log
564explains why:
565
566```text
567this run built f1f415ec but main is at 6beaa530 now; the site was not deployed
568```
569
570Only one job per workflow can publish the site. The branch name is set by the
571instance (`pages_ref`), not by the workflow.
572
573## What happens to a queued workflow
574
575Pushing again cancels queued runs for the commit it replaced. It never stops a run
576that is already in progress; a maintainer can stop that one from its page or with
577`vole run cancel <id>`, and the runner stops at its next heartbeat. The attempt that
578comes back is kept as canceled.
579
580A run can have more than one attempt. Each time a runner starts the job it records a
581new attempt, and a runner that disappears hands the job back for another one. After
582five attempts the run fails instead of cycling.
583
584An attempt that never reaches the job's first step ends as **could not run**, not
585**failed**: the image could not be built, the container would not start, or the
586network the job asked for could not be made. The run page shows the runner's own
587reason, and the check says the commit is not what failed, so a required check waits
588instead of going red. It is a rerun away from finishing. A step that exits non-zero
589is still a failure, and still reds the check, because that one is the code's. A finished run can be queued again
590with **Rerun** on its page or `vole run rerun <id>`; that is another attempt of the
591same pinned workflow, image, and secrets, not a new run. A queued or running run
592cannot be rerun. The run page lists each attempt's log, and the log URL takes
593`?attempt=N`.
594
595A run's status is `queued`, `running`, `success`, `failed`, `canceled`, or `error`,
596which the page calls **could not run**. Only the last two are not a verdict about the
597commit in the same way: a canceled run was stopped on purpose, and an `error` run
598never started.
599
600Finished runs are stored in `data/runs.db`, not on the repository's `vole` branch.
601For what happens when a runner disappears, how work is requeued, and how finished
602history is rebuilt, see [Runners § Scheduling and the queue](runner.md#scheduling-and-the-queue).
603