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 4: a published release cannot change under its link

948a6d7dcdacb72d6f90074ac0e5f9999314c4f4 · Keenan Nemetz · 17d ago · parent 85cc89fe

Phase 7 slice 4: a published release cannot change under its link

The last piece of the release story is the one that makes the download links
trustworthy: pre-receive now refuses to move or delete a tag that has artifacts
attached, and on the metadata branch refuses removing a pointer or re-pointing
one at different bytes. Comparing blob ids is enough — a pointer's id is its
text, so "unchanged" needs no parsing and cannot be fooled by reformatting.

Adding an artifact stays legal: an artifact is published when its pointer lands,
so an addition invalidates nothing.

The rules sit with the integrity checks rather than the review gate, because a
download that can change bytes is broken whoever pushed it. An admin may
override, with a line in data/audit.log, following the force-push precedent: a
mistyped tag is otherwise unrepairable, and an unaudited override leaves no trace
of the bytes that changed under somebody's link.

Four refusals are tested through a writer account (moved tag, deleted tag,
removed pointer, re-pointed pointer), plus the admin override and its audit line.

docs/roadmap.md +18−8

@@ -444,6 +444,17 @@ the second deduplicating to one object and no transfer; both downloaded in 13.9
444 444 with a matching `sha256`, `304` on `If-None-Match`, `206` on `Range: bytes=0-99`;
445 445 `vole gc --dry-run` reports the published artifact reachable and removes nothing.)
446 446
447 ✅ **Slice 4 — published releases are immutable.** `pre-receive` refuses to move or
448 delete a tag that `releases/<tag>/` has artifacts for, and on the metadata branch it
449 refuses removing a pointer or re-pointing one at different bytes (the blob id is the
450 pointer text, so "unchanged" needs no parsing). Additions are allowed, because an
451 artifact is published when its pointer lands and an addition invalidates nothing. The
452 rules sit with the integrity checks rather than the review gate — a download that
453 changes bytes is broken whoever pushed it — but an admin may override, with a line in
454 `data/audit.log`, because a mistyped tag is otherwise unrepairable. Four directions are
455 tested against a writer account: moved tag, deleted tag, removed pointer, re-pointed
456 pointer; and the audit line for the admin override.
457
447 458 Planned:
448 459
449 460 - Identity without ceremony: `vole whoami` resolves the key a person pushes with to an
@@ -488,14 +499,11 @@ Releases: decisions and what is left. Design settled by
488 499
489 500 Remaining:
490 501
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 502 - **Per-artifact size cap.** Artifacts ride `lfs_object_max_bytes` today, which is the
497 503 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.
504 config key to add when someone hits it, not before.
505 - **Signed artifacts.** Deferred with the rest of signing (see the signing block): the
506 only verifier available is the instance whose bytes are in question.
499 507
500 508 **Exit criteria:** a reviewer can approve a change using only the web — diff, checks and
501 509 approvals on one page — without cloning; a new user can go from `vole init` to a pushed
@@ -505,8 +513,9 @@ tagged release with an artifact attached over SSH downloads anonymously and matc
505 513 attached over SSH and downloaded in 13.9 ms with a matching sha256; the size in the
506 514 criterion is unmeasured — the fixture was 2.9 MiB, and `lfs_object_max_bytes` is what
507 515 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.
516 `TestReleaseArtifactsArePrivateForPrivateRepos`). A published artifact cannot be detached
517 or re-pointed ✅ (slice 4; proven by `TestPublishedReleaseIsImmutable`, which drives all
518 four refusals through a writer account).
510 519
511 520 ### Deferred — signing and audit
512 521
@@ -620,6 +629,7 @@ Filled in as phases land. Append; don't rewrite.
620 629 | 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 630 | 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 631 | 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 |
632 | 2026-10-22 | A published release is immutable, admin-overridable and audited | A link whose bytes can change is worse than no link; but refusing outright leaves a mistyped tag unrepairable, so the override follows the force-push precedent: admin only, always audited |
623 633
624 634 ## Open questions
625 635