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.
Phase 7 slice 7: the build deploys the site
88e980b0e88b24a20605ceeb9e42fd0dacee7842 · Keenan Nemetz · 17d ago · parent 213f4d6d
Phase 7 slice 7: the build deploys the site
pages: {path: site} on a job publishes that directory as the site branch, using
the same snapshot code `vole pages deploy` uses — a site published by a workflow
and one published by a person have to be the same commit shape, or the version
that drifts is the one nobody runs by hand.
- workflow: pages.path validated at push — one relative directory, no glob (a
site is one tree), and only one job per workflow may declare it, since two
publishers race and the winner is whichever runner finished last
- the directory is pinned into the queued run beside the steps and the artifact
patterns; the branch comes from the server (pages_ref), which a workflow
cannot know
- runner: after the artifacts, checks that the run's SHA is still the tip of its
branch and refuses otherwise. Deploy is a snapshot, so a stale build finishing
late would silently revert the site; the failure names both commits
- the site commit says where it came from: "site: main from run <id> (<sha>)"
Fixed on the way, caught by the end-to-end test: the shared push helper treated a
URL as a missing *named* remote, so a runner's deploy committed locally and
reported success while the site 404'd. A URL is now a remote in its own right.
Measured through the binary: two pushes queued two runs; the older one was
refused ("this run built f1f415ec but main is at 6beaa530 now; the site was not
deployed") and published nothing; the newer one deployed and the site host served
the new tree, with `git log pages` showing
"site: main from run 01M5MSC3... (6beaa530)".
docs/roadmap.md +24
| @@ -498,6 +498,27 @@ ran on a real runner, uploaded both over SSH, and the run page served them with | |||
| 498 | 498 | matching `sha256`; `vole gc --dry-run` reported 3 objects and 3 reachable roots — the | |
| 499 | 499 | log and both artifacts — removing nothing.) | |
| 500 | 500 | ||
| 501 | ✅ **Slice 7 — a site built by CI.** `pages: {path: site}` on a job publishes that | ||
| 502 | directory as the site branch, through the *same* snapshot code `vole pages deploy` uses | ||
| 503 | (`deploySite` in `cmd/vole/pages.go`), so a site published by a workflow and one | ||
| 504 | published by a person are the same commit shape. The directory is pinned into the run | ||
| 505 | at enqueue like the steps and the artifact patterns; the branch name comes from the | ||
| 506 | server (`pages_ref`) in the job payload, because instance configuration is not | ||
| 507 | something a workflow can know. The commit message names the build | ||
| 508 | (`site: main from run <id> (<sha>)`), so `git log pages` explains the tree. Two guards | ||
| 509 | earned their place: one job per workflow may declare `pages` (two would race, and the | ||
| 510 | winner would be whichever runner finished last), and a deploy is refused when the run's | ||
| 511 | SHA is no longer the tip of its branch — a snapshot deploy from a stale build silently | ||
| 512 | reverts the site, which is worse than a failed job. The refusal is a failed run whose | ||
| 513 | log says `this run built <a> but main is at <b> now; the site was not deployed`. | ||
| 514 | (Measured through the binary: two pushes queued two runs, the older one refused with | ||
| 515 | that message and published nothing, the newer one deployed and the site host served the | ||
| 516 | new tree; the commit on the pages branch read | ||
| 517 | `site: main from run 01M5MSC3... (6beaa530)`. Also fixed on the way: a URL handed to | ||
| 518 | the shared push helper was mistaken for a missing *named* remote, so a runner's deploy | ||
| 519 | committed locally and reported success — the end-to-end test caught it, because the | ||
| 520 | site served a 404 after a "successful" run.) | ||
| 521 | |||
| 501 | 522 | Planned: | |
| 502 | 523 | ||
| 503 | 524 | - Identity without ceremony: `vole whoami` resolves the key a person pushes with to an | |
| @@ -672,6 +693,9 @@ Filled in as phases land. Append; don't rewrite. | |||
| 672 | 693 | | 2026-10-22 | No release record file, and no drafts | The artifact list is the pointer directory and published-ness is the ref itself; a `releases/<tag>.json` would carry a flag that duplicates the tag and an edit history nobody asked for | | |
| 673 | 694 | | 2026-10-22 | Attach uploads bytes before it commits the pointer | The reverse order publishes a download that 404s; the hook cannot see bytes that were never in the pack, so the pointer check is the only guard and the GC grace period covers the gap | | |
| 674 | 695 | | 2026-10-22 | Artifact downloads are always an attachment, served through `http.ServeContent` | An uploaded `.html` rendered inline is stored XSS on the forge origin; `ServeContent` gives `Range` and conditional requests for free, and the LFS object id is the validator | | |
| 696 | | 2026-10-23 | CI deploys the site through the CLI's own snapshot code | A site published by a workflow and one published by a person must be the same commit shape; a second implementation would drift, and the version that drifts is the one nobody runs by hand | | ||
| 697 | | 2026-10-23 | A CI site deploy is refused when the run is not the branch tip | Deploy is a snapshot, so a stale build finishing late silently reverts the site; a failed job with a reason is the honest outcome | | ||
| 698 | | 2026-10-23 | One job per workflow may publish the site | Two publishers race and the winner is whichever runner finished last, which is not a decision anyone made | | ||
| 675 | 699 | | 2026-10-23 | Workflow outputs are declared (`artifacts:`), not CLI calls in steps | A workflow is validated at push, so a bad path fails the push; a step calling the CLI fails at run time, needs the binary on the runner's PATH, and gets idempotency wrong under retries | | |
| 676 | 700 | | 2026-10-23 | Run artifacts are named by the run queue, not by a ref | A build output is machine-written state; the metadata branch is for human-authored entities, and the sweep already takes extra roots from the queue for logs | | |
| 677 | 701 | | 2026-10-23 | A run's artifact list rides the finish message | Outputs and result are one fact: a separate call leaves a window where a successful run has no artifacts, or a failed one has some | | |