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.

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