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