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 2: a release is a tag the web can read
c56911e2c3ede0f70c7cde560042806dbf4b4ab0 · Keenan Nemetz · 17d ago · parent f76c635b
Phase 7 slice 2: a release is a tag the web can read A release is an annotated tag, so nothing new is stored: the tag message is the notes, the tree is the tarball, and both are already in every clone. - internal/gitx/tags.go lists refs/tags peeled to their commits and tells an annotated tag from a lightweight one; a lightweight tag's %(contents) is the commit message, so reading it as notes would invent a release description nobody wrote - internal/gitx/archive.go streams `git archive`; tarballs are generated per request and never stored, so they cannot disagree with the tag - internal/web/releases.go renders the list (lightweight tags labelled), the release page (notes through the same sanitiser as every pushed body, commits since the previous release, two downloads) and the archive handlers: ETag is the tag object id plus the format, conditional requests get 304, always Content-Disposition: attachment with nosniff, compressed streams queue behind a semaphore of 4 while plain tar does not - "since the previous release" is `git describe --abbrev=0 <tag>^`, not the next row of the tag list: two tags made in the same second sort arbitrarily, and a page that changes its baseline between requests is worse than one without one - the page warns when the tagged tree tracks LFS, because git archive copies pointer files verbatim (measured in spike D) - cmd/vole release create writes the annotated tag and refuses an empty message or an existing name: re-tagging is how a download link comes to mean different bytes Measured end to end against the binary with an 11.6 MiB, 2,566-file repository: release list 6.1 ms, release page 12.6 ms, .tar 53.7 ms / 13.7 MiB, .tar.gz 218 ms / 1.8 MiB, conditional request 304. Every file in the served tarball hashed to its ls-tree blob id (2,565 of 2,565).
docs/roadmap.md +36−17
| @@ -402,6 +402,28 @@ when they are cut, binary files are named rather than faked, and every line is e | |||
| 402 | 402 | execute. A repository's README renders on its home page, and markdown blobs render as | |
| 403 | 403 | markdown, through the same sanitiser issues use. | |
| 404 | 404 | ||
| 405 | ✅ **Slice 2 — releases are tags, on the web.** `internal/gitx/tags.go` lists | ||
| 406 | `refs/tags` peeled to the commits they name, and `internal/web/releases.go` renders | ||
| 407 | them: `/r/<repo>/releases` lists every tag and labels the lightweight ones, and | ||
| 408 | `/r/<repo>/releases/<tag>` shows the tag message as notes (through the same markdown | ||
| 409 | sanitiser as every other pushed body), the commits since the previous release, and two | ||
| 410 | downloads. Tarballs are generated per request by `git archive` and never stored, so a | ||
| 411 | tarball cannot disagree with its tag; the `ETag` is the tag object id plus the format, | ||
| 412 | and a conditional request gets a `304`. `vole release create <tag> -m|-F` writes the | ||
| 413 | annotated tag and refuses the two mistakes that produce a release page with nothing on | ||
| 414 | it: an empty message and an existing tag. "Since the previous release" is | ||
| 415 | `git describe --abbrev=0 <tag>^` rather than the next row in the tag list, because two | ||
| 416 | tags created in the same second sort arbitrarily and a page that changes its baseline | ||
| 417 | between requests is worse than one with no baseline. Compressed archives queue behind a | ||
| 418 | semaphore of 4; plain `tar` does not, because it streamed at 5,005 MiB/s under 8 | ||
| 419 | concurrent requests. `git archive` copies LFS pointers verbatim, so the page warns when | ||
| 420 | the tagged tree tracks LFS, which is the honest alternative to resolving pointers while | ||
| 421 | streaming. | ||
| 422 | (Measured on an 11.6 MiB, 2,566-file repository served by the binary: release list | ||
| 423 | 6.1 ms, release page 12.6 ms, `.tar` 53.7 ms / 13.7 MiB, `.tar.gz` 218 ms / 1.8 MiB, | ||
| 424 | conditional request `304`; every file in the served tarball hashed to its `ls-tree` blob | ||
| 425 | id, 2,565 of 2,565.) | ||
| 426 | |||
| 405 | 427 | Planned: | |
| 406 | 428 | ||
| 407 | 429 | - Identity without ceremony: `vole whoami` resolves the key a person pushes with to an | |
| @@ -414,14 +436,14 @@ Planned: | |||
| 414 | 436 | empty state that prints the exact command that fills it. | |
| 415 | 437 | - Per-file history (`/commits/<ref>/<path>`), so "when did this change" is one click. | |
| 416 | 438 | ||
| 417 | Releases and artifacts — design settled by [spike D](spikes/archive-streaming/README.md), | ||
| 439 | Release artifacts — design settled by [spike D](spikes/archive-streaming/README.md), | ||
| 418 | 440 | nothing built yet: | |
| 419 | 441 | ||
| 420 | - **A release is an annotated tag; its record is `releases/<tag>.json` on | ||
| 421 | `refs/heads/vole`.** The tag is the authority — immutable, visible to every clone, | ||
| 422 | `git show v1.2.0` works — and the record carries what a tag cannot: draft versus | ||
| 423 | published, artifact names, and an edit history. A lightweight tag is refused for a | ||
| 424 | release, because a tag with no tagger has nobody to attribute it to. | ||
| 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. | ||
| 425 | 447 | - **An artifact is a literal LFS pointer blob** at `releases/<tag>/<name>`. Measured: | |
| 426 | 448 | `gc.Run` marks only blobs that `lfs.ParsePointer` accepts (`internal/gc/gc.go:84`) | |
| 427 | 449 | and walks them from `rev-list --objects --all` (`internal/gitx/gitx.go:705`), which | |
| @@ -437,17 +459,13 @@ nothing built yet: | |||
| 437 | 459 | for 13× the CPU, and `core.compression` is inert (levels 1, 6 and 9 produce | |
| 438 | 460 | identical bytes). Only compressed requests need a semaphore; 8 concurrent plain tar | |
| 439 | 461 | streams ran at 5,005 MiB/s against 273 MiB/s gzipped. | |
| 440 | - **A source archive of an LFS repository contains pointers, not bytes** (measured), | ||
| 441 | so the download says so. Resolving pointers from the store while streaming is the | ||
| 442 | change that would make the tarball complete, and the trigger to do it is the first | ||
| 443 | repository where a tarball of pointers is not acceptable. | ||
| 462 | - **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. | ||
| 444 | 466 | ||
| 445 | Slices: | ||
| 467 | Remaining slices: | ||
| 446 | 468 | ||
| 447 | - **Slice 2 — release from a tag.** Annotated tags listed on the repository page and | ||
| 448 | shown at `/r/<repo>/releases/<tag>` with the tag message as notes, commits since the | ||
| 449 | previous tag, and a streamed source archive; `vole release create <tag> -F <file>`. | ||
| 450 | Does not touch artifacts. | ||
| 451 | 469 | - **Slice 3 — artifacts.** `vole release attach <tag> <file>...` uploads over SSH | |
| 452 | 470 | through the existing `put-object` path, then commits the pointer on the metadata | |
| 453 | 471 | branch — bytes first, ref second, and the GC grace period covers the gap. Downloads | |
| @@ -464,8 +482,9 @@ Slices: | |||
| 464 | 482 | approvals on one page — without cloning; a new user can go from `vole init` to a pushed | |
| 465 | 483 | commit following the README alone; every page has a working path to its neighbours; a | |
| 466 | 484 | tagged release with a 100 MiB artifact attached over SSH downloads anonymously and | |
| 467 | matches `sha256sum` locally, and returns `404` on a private repository (unmet: the | ||
| 468 | release slices are not built, so this criterion is unmeasured). | ||
| 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`). | ||
| 469 | 488 | ||
| 470 | 489 | ### Deferred — signing and audit | |
| 471 | 490 | ||