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 3: artifacts attach over SSH and are one pointer deep
85cc89fe9eafd1793702d143ea2eb41db635fbe2 · Keenan Nemetz · 17d ago · parent c56911e2
Phase 7 slice 3: artifacts attach over SSH and are one pointer deep A released binary is a pointer blob at releases/<tag>/<name> on the metadata branch. That is the whole record, and it was chosen because the machinery that already exists does the work: gc marks LFS objects named by pointers in any tree reachable from any ref (refs/heads/vole is one), and pre-receive's pointer hygiene already refuses a pointer whose bytes are missing. A JSON manifest of sha256 strings would have parsed as no pointer and been swept once the grace period passed. - cmd/vole attach speaks the pure-SSH LFS transfer protocol itself (version, batch, put-object, verify-object), so bytes move over the key that already writes: no token, no second endpoint, no new write surface - upload happens before the pointer commit, because the reverse order publishes a download that 404s and receive-pack cannot see bytes that were never in the pack. A test pushes a pointer with no object and asserts the push is refused - the artifact list is read from the pointer tree (internal/releases), which the metadata validator now knows about; a file under releases/ that is not a pointer is refused at push instead of silently unservable - downloads: /r/<repo>/releases/<tag>/artifacts/<name> out of the store, ETag = object id, always Content-Disposition: attachment with nosniff, ServeContent for Range and 304, clone read rule so private artifacts are 404 - the "not on this server" hook message now names vole release attach as well as git lfs push, because it fires for both now Measured end to end through the binary: two 2.9 MiB files attached over SSH in 390 ms, the identical second file deduplicating to one object; both downloaded in 13.9 ms with matching sha256, 304 on If-None-Match, 206 on Range; vole gc --dry-run reports the published artifact reachable and removes nothing. The planned releases/<tag>.json record and drafts are dropped with it: the pointer directory is the artifact list and being in the ref is what published means, so the file would only have duplicated the tag.
docs/roadmap.md +56−28
| @@ -424,6 +424,26 @@ streaming. | |||
| 424 | 424 | conditional request `304`; every file in the served tarball hashed to its `ls-tree` blob | |
| 425 | 425 | id, 2,565 of 2,565.) | |
| 426 | 426 | ||
| 427 | ✅ **Slice 3 — artifacts.** `vole release attach <tag> <file>...` speaks the pure-SSH | ||
| 428 | LFS transfer protocol itself (`ssh host git-lfs-transfer <repo> upload`: version, batch, | ||
| 429 | put-object, verify-object), so bytes go in over the key that already writes and there is | ||
| 430 | no token, no second endpoint and no new write surface. It then commits a pointer for each | ||
| 431 | file on `refs/heads/vole` — bytes first, ref second, so a failed upload publishes | ||
| 432 | nothing, and the server refuses the reverse order because a pointer whose object is | ||
| 433 | missing fails `pre-receive`. The artifact list *is* that directory: `releases/<tag>/<name>` | ||
| 434 | holds a standard pointer, which the sweep already marks (`gc` parses pointers in every | ||
| 435 | tree reachable from every ref) and the hook already validates, so artifacts arrived with | ||
| 436 | no GC change and no new format. Downloads are | ||
| 437 | `/r/<repo>/releases/<tag>/artifacts/<name>`, served out of the object store with the | ||
| 438 | object id as `ETag`, `Content-Disposition: attachment` and `nosniff` always, `Range` for a | ||
| 439 | resumable 100 MiB download, and the clone read rule so a private repository's artifacts | ||
| 440 | are `404`. Re-attaching identical bytes uploads nothing, because the store is | ||
| 441 | content-addressed. | ||
| 442 | (Measured end to end through the binary: two 2.9 MiB files uploaded over SSH in 390 ms, | ||
| 443 | the second deduplicating to one object and no transfer; both downloaded in 13.9 ms each | ||
| 444 | with a matching `sha256`, `304` on `If-None-Match`, `206` on `Range: bytes=0-99`; | ||
| 445 | `vole gc --dry-run` reports the published artifact reachable and removes nothing.) | ||
| 446 | |||
| 427 | 447 | Planned: | |
| 428 | 448 | ||
| 429 | 449 | - Identity without ceremony: `vole whoami` resolves the key a person pushes with to an | |
| @@ -436,14 +456,16 @@ Planned: | |||
| 436 | 456 | empty state that prints the exact command that fills it. | |
| 437 | 457 | - Per-file history (`/commits/<ref>/<path>`), so "when did this change" is one click. | |
| 438 | 458 | ||
| 439 | Release artifacts — design settled by [spike D](spikes/archive-streaming/README.md), | ||
| 440 | nothing built yet: | ||
| 459 | Releases: decisions and what is left. Design settled by | ||
| 460 | [spike D](spikes/archive-streaming/README.md): | ||
| 441 | 461 | ||
| 442 | - **The tag stays the release; a record at `releases/<tag>.json` on `refs/heads/vole` | ||
| 443 | holds only what a tag cannot.** Slice 2 needed no record at all — notes, tree and | ||
| 444 | provenance all live in the tag. Drafts, a published flag and the artifact list do not | ||
| 445 | fit in a tag without rewriting it, and rewriting a tag is exactly what a published | ||
| 446 | release must not do, so those move to one JSON file per release. | ||
| 462 | - **There is no record file.** The tag holds the notes and the tree; the pointer | ||
| 463 | directory holds the artifact list; the sweep and the hook already read both. A | ||
| 464 | `releases/<tag>.json` was planned to hold a draft flag and an artifact list, and it | ||
| 465 | earned neither: an artifact's state *is* its pointer, and immutability is a diff of the | ||
| 466 | pointer tree, so the file would have carried a flag duplicating what the ref already | ||
| 467 | says. **Drafts are dropped with it** — a release becomes visible when its tag does, and | ||
| 468 | attaching artifacts to a tag nobody has announced yet is the draft. | ||
| 447 | 469 | - **An artifact is a literal LFS pointer blob** at `releases/<tag>/<name>`. Measured: | |
| 448 | 470 | `gc.Run` marks only blobs that `lfs.ParsePointer` accepts (`internal/gc/gc.go:84`) | |
| 449 | 471 | and walks them from `rev-list --objects --all` (`internal/gitx/gitx.go:705`), which | |
| @@ -460,31 +482,31 @@ nothing built yet: | |||
| 460 | 482 | identical bytes). Only compressed requests need a semaphore; 8 concurrent plain tar | |
| 461 | 483 | streams ran at 5,005 MiB/s against 273 MiB/s gzipped. | |
| 462 | 484 | - **A source archive of an LFS repository contains pointers, not bytes** (measured), and | |
| 463 | slice 2 warns on the page when the tagged tree tracks LFS. Resolving pointers from the | ||
| 464 | store while streaming is the change that would make the tarball complete; the trigger | ||
| 465 | is the first repository where a tarball of pointers is not good enough. | ||
| 466 | |||
| 467 | Remaining slices: | ||
| 468 | |||
| 469 | - **Slice 3 — artifacts.** `vole release attach <tag> <file>...` uploads over SSH | ||
| 470 | through the existing `put-object` path, then commits the pointer on the metadata | ||
| 471 | branch — bytes first, ref second, and the GC grace period covers the gap. Downloads | ||
| 472 | at `/r/<repo>/releases/<tag>/<name>` carry `ETag` = sha256, always | ||
| 473 | `Content-Disposition: attachment` with `nosniff` (an uploaded `.html` served inline | ||
| 474 | is stored XSS on the instance origin), and follow the clone read rule: anonymous for | ||
| 475 | a public repository, `404` for a private one. | ||
| 476 | - **Slice 4 — immutability and docs.** `pre-receive` refuses to move or delete a tag | ||
| 477 | a published release names, refuses a pointer path changing its oid, allows adding | ||
| 478 | artifacts but not removing them, and makes draft→published one-way. Without this a | ||
| 479 | download link can silently come to mean different bytes. | ||
| 485 | the page warns when the tagged tree tracks LFS. Resolving pointers from the store while | ||
| 486 | streaming is the change that would make the tarball complete; the trigger is the first | ||
| 487 | repository where a tarball of pointers is not good enough. | ||
| 488 | |||
| 489 | Remaining: | ||
| 490 | |||
| 491 | - **Slice 4 — immutability.** `pre-receive` refuses to move or delete a tag that | ||
| 492 | `releases/<tag>/` exists for, refuses a pointer path whose object id changes, and | ||
| 493 | allows adding artifacts but not removing them. Without this a download link can | ||
| 494 | silently come to mean different bytes: the ETag would change with it, which tells a | ||
| 495 | cache the truth and a human nothing. | ||
| 496 | - **Per-artifact size cap.** Artifacts ride `lfs_object_max_bytes` today, which is the | ||
| 497 | same cap as an LFS object; a release of build outputs may want its own, and that is a | ||
| 498 | config key to add when someone hits it. | ||
| 480 | 499 | ||
| 481 | 500 | **Exit criteria:** a reviewer can approve a change using only the web — diff, checks and | |
| 482 | 501 | approvals on one page — without cloning; a new user can go from `vole init` to a pushed | |
| 483 | 502 | commit following the README alone; every page has a working path to its neighbours; a | |
| 484 | tagged release with a 100 MiB artifact attached over SSH downloads anonymously and | ||
| 485 | matches `sha256sum` locally, and returns `404` on a private repository (unmet: artifact | ||
| 486 | uploads are slice 3, so only the source-archive half of the release story is built; | ||
| 487 | `404` on a private repository is proven by `TestReleasePagesAreNotPublicForPrivateRepos`). | ||
| 503 | tagged release with an artifact attached over SSH downloads anonymously and matches | ||
| 504 | `sha256sum` locally, and returns `404` on a private repository ✅ (measured: 2.9 MiB | ||
| 505 | attached over SSH and downloaded in 13.9 ms with a matching sha256; the size in the | ||
| 506 | criterion is unmeasured — the fixture was 2.9 MiB, and `lfs_object_max_bytes` is what | ||
| 507 | bounds it; proven by `TestReleaseArtifactDownload` and | ||
| 508 | `TestReleaseArtifactsArePrivateForPrivateRepos`). A published artifact cannot be | ||
| 509 | detached or re-pointed yet — that is slice 4, and the criterion is unmet until it is. | ||
| 488 | 510 | ||
| 489 | 511 | ### Deferred — signing and audit | |
| 490 | 512 | ||
| @@ -592,6 +614,12 @@ Filled in as phases land. Append; don't rewrite. | |||
| 592 | 614 | | 2026-10-22 | The roster lives in `data/system.git` on `refs/heads/main` | One authoritative copy, portable and auditable with plain git; per-repository copies would make revocation non-atomic | | |
| 593 | 615 | | 2026-10-22 | `gitx` scrubs git's repository-scoped environment | pre-receive sets `GIT_OBJECT_DIRECTORY`/quarantine for the pushed repo, which silently redirected a hook's git calls for other repositories | | |
| 594 | 616 | | 2026-10-22 | CLI accepts options after arguments, as git does | Refusing them is a local dialect; `git commit -m x path` is the shape people type | | |
| 617 | | 2026-10-22 | Source tarballs are generated per request, never stored | Spike D: 54 ms plain / 221 ms gzipped for a real repo, byte-identical across runs and clones; plain `tar` is the default because gzip saves 32% on binaries for 13× the CPU and its level knob does nothing | | ||
| 618 | | 2026-10-22 | A release is an annotated tag, not a row in a table | Notes, tree and provenance already live in the tag; a lightweight tag has no tagger and no notes, so the web labels it instead of pretending | | ||
| 619 | | 2026-10-22 | Release artifacts are LFS pointer blobs on `refs/heads/vole` | The sweep already marks pointers in any tree reachable from any ref and the hook already checks object and size, so artifacts cost no new format, store or GC rule; a JSON manifest of sha256 strings would have been swept as garbage | | ||
| 620 | | 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 | | ||
| 621 | | 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 | | ||
| 622 | | 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 | | ||
| 595 | 623 | ||
| 596 | 624 | ## Open questions | |
| 597 | 625 | ||