- Feature Name:
unversioned-and-releases(fossil's unversioned files in zit and quarry: synced to every clone, served at fossil's paths, imported from mirrored fossils, and the tier quarry's own releases are published on) - Start Date: 2026-09-30
- Status: Built except the release workflow (2026-10-01). Landed: the
catalog (!782), the sync front (!785), the web pages (!784), mirror
import (!789), the tag event, VERSION stamp and deploy.sh --release
and --rollback (!788). Waiting: scripts/release.sh and ci/release.yml,
which publish with
zit uv push(zit#226), then the first tagged release. Decisions settled 2026-10-01: bytes in the repository admin's bucket, served by quarry's sync front; no uv without a bucket; releases cut by tag, deploys separate and by hand; no tag syntax. - Amends: RFC 0019 §
on:(atagevent, so a release workflow can start from a tag); docs/fossil-paths.md §/uv/<name>(the bytes are in the repository's bucket, not a forge default; see Where the bytes live) - Supersedes: quarry ticket #19's direction (release binaries as forge-only attachments), see Motivation
- Depends on: RFC 0016 (the repository's bucket binding, which uv
content now shares); RFC 0001 §7 (sync fronting), §11 (the
disposability test);
RFC 0004 (letters:
ywrites,oreads); RFC 0008 (roles over the letters); RFC 0009 and RFC 0019 (the release workflow runs as a job with an environment) - Upstream: zit, two issues named in Build order, needed by the release flow only
Summary
Fossil keeps a second kind of content beside the artifact DAG: unversioned
files, "files stored in a Fossil repository without history, meaning it
retains the newest version of each such file, and that alone" (fossil
www/unvers.wiki). They sync between repositories with fossil sync -u,
the newest mtime wins, a deletion is a tombstone, a server accepts them
from a user holding y and shows them to a user holding o, and the
web serves them at /uv/<name>. Fossil and sqlite publish their own
downloads this way, and zit's releases are published this way to a stock
fossil server that quarry is meant to replace.
Today zit validates uvfile cards and does not act on them (zit#58), and
quarry's sync gate fails closed on one (scanRequest in
internal/xfer/container.go). A fossil repository mirrored onto quarry
loses every download link in its own documentation.
This RFC puts the tier in:
- The bytes live in the repository's own bucket, the S3-compatible
bucket its admin binds for attachments (RFC 0016), under
unversioned/<repo>/. A repository with no bucket bound has no unversioned files, and says so. The forge stores none and sets no quota: the owner's bucket and the binding's own per-file limit are the bounds. - quarry's sync front speaks the protocol (
pragma uv-hash,uvigot,uvgimme,uvfile) for a repository it hosts, from the bucket, and hands zit only the DAG part of a request. A stock fossil client syncs unversioned content with quarry, and/uv/<name>serves it. - zit gets a stateless client (
zit uv list|pull|push|rm URL) that moves unversioned files between a fossil-speaking server, quarry included, and plain files. A zit repository keeps no copy of them. - Mirrors get one switch: import the upstream's unversioned files into the mirror's bucket on every pull, latest wins, tombstones honoured; an upstream that does not share them, or a mirror with no bucket, says so in the mirror's status and the pull goes on.
- Releases and deploys are separate. A release is a tag on trunk
that a repository's release workflow recognises by its own glob (no
syntax is imposed); the workflow builds and publishes the artifacts
to the repository's unversioned files. Deploying quarry stays a
person's act,
deploy.sh --release X.Y.Z, never a side effect of accepting a landing.
Motivation
Three things want the same tier.
Mirrors break without it. zeitforge hosts fossil's and sqlite's own
repositories as mirrors. Their documentation links /uv/download.html,
/uv/fossil-linux-x64-2.25.tar.gz and the like; docs/fossil-paths.md
already marks /uv/<name> as "the one with a clear path" and says what
breaks: "the download links in that repository's own documentation".
zit's releases need a home that is not a stock fossil server. The
release skill publishes with fossil uv add releases/X.Y.Z/<file> --as
..., serves download.html and download.js from the same store, and
syncs with fossil sync -u. quarry exists to replace that server. A forge
that cannot hold the release downloads of the tool it is a forge for has
not replaced anything.
Releases that sync are not forge-only. quarry ticket #19 chose the
opposite: "Release binaries are forge-hosted, not repository content ...
upload through the UI, store the bytes the way attachments are already
stored and served by hash, and serve them from a per-release download
page", and called the unversioned tier "zit verb and protocol work quarry
cannot do, and which is not wanted". This RFC supersedes that direction,
decided by the owner, for the reasons above plus one: unversioned content
travels with sync -u to every clone that asks, so a release published
on quarry is also on every developer's machine and on every mirror, which
a forge-only attachment never is. That is fossil's design and the reason
fossil has the tier at all.
What #19 got right stands: checksums come from release-manifest.json
and SHA256SUMS, there is a per-release page, the upload is a form on
the forge for anyone holding the letter, the bytes live where
attachments do, in the bucket the repository's admin supplies, and
large binaries never enter the DAG. Unversioned content is outside the artifact graph by
definition; the newest version replaces the last, so nothing accumulates.
Fossil's semantics, as relied on
Everything below is cited from fossil's www/sync.wiki §3.3.4, §3.6.1,
§3.7.1, §3.11, §5.4, www/unvers.wiki, www/caps/ref.html, and the
server's own source where the documents are silent (src/xfer.c,
src/unversioned.c at trunk).
| Fact | Source |
|---|---|
A file is name, mtime (seconds since 1970), content hash, size. Only the version with the largest mtime is kept. |
sync.wiki §5.4 |
uvfile NAME MTIME HASH SIZE FLAGS \n CONTENT, both directions. HASH is - for deleted content. Flag 0x0004: content omitted (too large, or only the mtime changed). The receiver accepts only if hash and size match the content and the mtime is newer than what it holds. |
sync.wiki §3.3.4 |
uvigot NAME MTIME HASH SIZE is the catalog, one per file the server holds; uvgimme NAME asks for one. |
§3.6.1, §3.7.1 |
The client sends pragma uv-hash H (a hash over every name, mtime and hash it holds). A server that holds something different answers pragma uv-push-ok when the user has y, else pragma uv-pull-only, then the catalog. Both replies need o (g.perm.Read); without it the server answers nothing and the client learns the server does not share. unvers.wiki says instead that "a user needs the 'y' capability on the remote before they can sync unversioned content"; the source is the authority for the read half, and the mirror test against fossil 2.28 settled it: nobody holding o and no y is sent uv-pull-only and the catalog; without o it is sent nothing, and its pull is refused too. |
§3.11; xfer.c, the uv-hash handler; unvers.wiki |
A server accepts a uvfile card only from a user holding y ("Write permissions for unversioned files missing"). |
§3.3.4; xfer.c xfer_accept_unversioned_file |
y, WrUnver: "Push unversioned content". o, Read. a does not grant y; s does (RFC 0004 keeps this). "Even the all-powerful setup user does not get unversioned file sync capability by default." |
caps/ref.html; unvers.wiki |
A deletion keeps the row: hash=NULL, content=NULL, sz=0, with a new mtime. That tombstone is what syncs. |
unversioned.c, uv rm |
/uvlist needs o; /uv/<name> serves the content. |
unversioned.c, doc.c |
sync -u and clone -u carry unversioned content; push and pull have no -u. uv sync moves only unversioned content. The uv-sync setting makes a server send its catalog to every clone unasked. |
unvers.wiki; db.c |
Names: no whitespace, no complex paths, at most 500 bytes; a directory prefix like releases/1.2.3/ is allowed and is only a name. A file larger than SQLite's blob limit is refused. |
unversioned.c |
| The content hash is the repository's hash policy (SHA1 or SHA3-256). | §3.6.1 |
Where the bytes live
In the repository's bucket. RFC 0016's binding (internal/oltp/bucket.go:
endpoint, bucket, region, a sealed key, public_base, limit_bytes)
is the repository admin's storage, and unversioned content is stored
there beside attachments, under a prefix of the repository's own:
unversioned/<repo>/<name>: the newest content of each file, with its fossilmtimeand contenthashas object metadata;unversioned/<repo>/.tombstones/<name>: an empty object whose metadata carries the deletion'smtime, which is what syncs a removal.
The repository's name is in the key because RFC 0016 lets several
repositories bind one bucket. Attachments can share it safely, since
their keys are content hashes; an unversioned file's key is its name,
so without the repository two repositories' download.html would be
one object, and each one's rebuild would import the other's files. A
rebuild lists unversioned/<repo>/ alone. A repository name
(oltp.RepoName: letters, digits, ., _, -, starting with a
letter or digit) is one key segment that cannot start with ., so no
repository's prefix holds another's, and a name cannot reach
.tombstones/ since that prefix is refused for a file name.
The bucket is the record. quarry keeps a catalog in OLTP (name, mtime,
hash, size, deleted, who) so the listing and uv-hash never list the
bucket on a request path, and rebuilds it from a bucket listing and the
objects' metadata when it is missing or a different bucket is bound.
That is RFC 0001 §11's test passed the way RFC 0016 passes it: the
bytes are the steward's, outside the forge, and nothing the forge holds
is the only copy.
No bucket, no unversioned files. The listing says "bind a bucket in
Settings to publish unversioned files", the sync front answers a
uv-hash the way a fossil server answers a user without o (nothing),
and the mirror import switch is unavailable. The forge owns no storage
for this, as RFC 0016 owns none for attachments; docs/fossil-paths.md's
"forge bucket as the default" is withdrawn.
No quota. The bucket is the owner's and writing takes y, a
letter granted by hand. The one bound is the binding's own
limit_bytes, the per-file limit its admin already sets for
attachments (default 100 MiB), and, for a uvfile arriving by sync,
the sync front's request cap (maxRequest, internal/xfer/xfer.go,
256 MiB), since a card is one request. A file over either is refused
by name, never partially applied.
Downloads are presigned GETs from the bucket (or its public_base),
as attachments are (internal/s3/s3.go, PresignGet), so the forge
never streams the bytes.
Who. Fossil records only the receive id. The catalog row carries
who wrote it: the signed-in uploader, the sync login that pushed, or
mirror:<host> for an import. A tombstone keeps its row, so a removal
that arrived by sync still says who removed what. The uv.changed
event joins RFC 0005's vocabulary, with the name and whether it was a
deletion.
zit: verbs and wire
zit#58 chose "tolerate and report" for uvfile; this replaces it on the
client side. quarry answers the server side itself, so zit's serve does
not need it for a quarry-hosted repository.
No local store (decided 2026-10-02). quarry keeps a repository's
unversioned files in its bucket and answers the cards itself, so a zit
repository has no reason to hold a copy. An earlier draft gave zit an
unversioned relation, local uv add|list|cat|export|rm|hash verbs
and an import-fossil that carried the table (zit#225); that is
withdrawn, and zit#225 is closed as not planned. sync, push,
pull and clone keep leaving uv cards alone; sync -u and
clone -u are out of scope.
A stateless client (zit#226) moves unversioned files between a
fossil-speaking server, quarry or a stock fossil server, and plain
files, never storing them in .zit:
| verb | does |
|---|---|
zit uv list URL [-l] |
the server's catalog; with -l mtime, hash, size, and deleted for a tombstone, one record per line |
zit uv pull URL NAME... [-o DIR], zit uv pull URL --all [-o DIR] |
fetch files into a directory |
zit uv push URL FILE... [--as NAME] [--mtime T] |
publish files; replaces a name whose mtime is older |
zit uv rm URL NAME... |
publish a removal (the tombstone) |
It speaks fossil's wire: pragma uv-hash, uv-push-ok or
uv-pull-only, uvigot, uvgimme, uvfile, tombstones as hash -,
with fossil's y (to push) and o (to read) behaviour.
Whether zit's own serve answers unversioned content for a repository
nobody fronts is zit's choice and outside this RFC.
quarry: the sync front
scanRequest (internal/xfer/container.go) frames a request's cards to
gate pushes and today returns failClosed() on uvfile. It learns the
unversioned cards: uvfile has five tokens, SIZE fourth, and SIZE
payload bytes follow the line unless flag 0x0004 is set; uvgimme
and pragma uv-hash are one line each. Fail-closed stays for a card it
cannot frame.
quarry answers them itself. The front splits a request: the unversioned cards it handles from the catalog and the bucket, and everything else goes to the zit child as today. Its reply to the client is the child's reply with the unversioned cards appended, which fossil's message format allows (a reply is a sequence of cards). So:
| request carries | needs | quarry answers |
|---|---|---|
pragma uv-hash H |
the read gate (ReadGate): o on a private repository, anyone on a public one, and a bound bucket |
when H differs from the catalog's hash: pragma uv-push-ok if the login holds y, else uv-pull-only, then a uvigot per catalog entry; otherwise nothing |
uvgimme NAME |
the read gate | uvfile with the content from the bucket, or uvigot again when the reply is already large, as fossil does |
uvfile ... |
a verified login holding y, the write guard (WriteGuard), the size bounds, a bound bucket |
accepted by fossil's rule (hash and size match, mtime newer): object written, catalog row updated, uv.changed; refused with fossil's error card and the name otherwise, and the DAG part of the request is not applied either (fossil rejects the whole message) |
| none of them | unchanged | unchanged |
A tracking mirror (accept_writes off, internal/oltp/mirror.go) is
uv-pull-only for everyone, as it takes no pushes: the forge's copy of
someone else's repository takes no local writes, unversioned ones
included. A mirror with accept_writes on takes them, and the next
import applies fossil's rule: the newer mtime wins, whichever side
wrote it.
Private repositories: MayMirror (internal/oltp/repo.go) already
answers no for a private repository, and the read gate already admits a
verified login that may read it. Unversioned content rides both.
Letters and roles
RFC 0004 §4 forbids a new letter and none is needed: y is in
internal/oltp/capref.go already, listed as "Write-UV: Push unversioned
content" and inert. It stops being inert. The op table gains:
| op | letter | fossil surface |
|---|---|---|
uv.list, uv.get |
o |
/uvlist, /uv/NAME, the catalog |
uv.put, uv.rm |
y |
uvfile |
| the bucket binding (RFC 0016) | a |
none; fossil keeps the content in the repository file |
RFC 0008's roles do not change. Admin stays "everything except setup,
private branches, unversioned writes", which is fossil's rule and the
right one: an administrator of settings is not thereby a publisher of
binaries. An account that publishes is Custom, read by 0008 as
"Maintainer, plus push unversioned content", and that sentence is the
whole interface. s holds y everywhere, as it holds everything.
The forge's own release account (Releases) is an ordinary
account with y on the quarry repository and nothing else.
The web
Listing, /r/{repo}/uv, visible to whoever may read the repository:
name, size, hash (prefix, full on hover), mtime, who (from the catalog),
with tombstones in a collapsed "removed" section so a reader can see what
used to be there and when it went. Sortable by name and by age, as
fossil's /uvlist is. With no bucket bound, the page says so and, for
a, links the bucket settings.
Download, /r/{repo}/uv/{name}, with name allowed to contain /:
a redirect to a presigned GET on the bucket (or its public_base), as
attachments are served (PresignGet in internal/s3/s3.go), so the
forge never streams the bytes. The type comes from the extension by a
fixed table and rides the presigned URL's response-content-type.
HTML, SVG, JavaScript and anything else that would run in a browser is
served with response-content-disposition: attachment, the rule RFC
0016 applies to attachments. This differs from fossil, which renders
/uv/download.html as a page. Published HTML is one y grant from
script in a reader's browser; the releases page below is the
replacement for a hand-written download page.
Fossil's paths: /uv/<name> and /uvlist join FossilRoute
(docs/fossil-paths.md), so a link in a mirrored repository's wiki
resolves. Its line moves from "Future work" to "Mapped".
Upload and remove, for holders of y: the listing page's form,
name defaulting to the file's, optional prefix, replaced on the same
name; a remove button per row. The upload takes RFC 0016's path: the
form through the forge up to the binding's limit, or straight to the
bucket by a presigned PUT when the bucket allows the forge's origin
(the binding's CORS probe). Both end in uv.put and uv.rm, which
write or tombstone the object, update the catalog with the mtime and
who, and emit uv.changed. Uploads are not held or screened: y is
the trust, and fossil's own posture ("uncommonly dangerous ... no
history to revert to") is why the letter is granted by hand.
Releases, /r/{repo}/releases: every name under releases/<name>/
grouped by that folder, newest first (by semantic order when the
folder names are versions, else by date), with the newest marked. The
folder name is the release's name, whatever the repository's tags look
like (Releases). When the group holds
release-manifest.json, the page reads it: each artifact's target,
size and sha256, the source check-in (linked), the date, and the
qualification notes if present, in the schema zit's releases already
use (schema_version: 1, keys artifacts, omitted_targets,
qualification, release, source). Without a manifest, the group is
a list of files with SHA256SUMS beside them, and the page says
checksums come from the file. A CHANGELOG.md section whose heading
names the release is shown beside it, the way /changelog renders the
file today (changelogPage, internal/web/web.go). The repository
tabs show Releases only when a releases/ prefix exists.
Mirrors
A mirror pulls with zit pull URL (Client.Pull in
internal/zitcli/zitcli.go, called from PullMirror in
internal/api/register.go) on its own interval, and the mirror table
in internal/oltp/mirror.go holds repo, url, every, enabled and its
history. Two additions:
uv, a switch per mirror, off by default, on the fleet page's mirror
form (/fleet?tab=mirrors) where the address and interval are set, and
shown read-only in the repository's own Mirror section with its last
result. It needs the mirror's repository to have a bucket bound; without
one the switch is shown disabled with the reason. When on, each pull is
followed by an import round that quarry runs itself, as the client half
of the same cards its sync front answers: pragma uv-hash (with the
login card when a credential is set), the uvigot catalog back,
uvgimme for every name the upstream holds newer than the catalog, and
each uvfile written to the bucket as an upload would be. The bytes go
from the upstream to the bucket and nowhere else. Latest mtime wins, as
in fossil, so a version an upstream stamped in the far future wins here
until something newer still replaces it; a
tombstone upstream becomes a tombstone here; a local file the upstream
never had stays (relevant only with accept_writes). The round is
recorded in mirror_pull beside the DAG pull with its own uv_changed
and uv_err, so "the code is current but the downloads are three days
old" is visible.
A credential per mirror, user and password for the upstream, sealed
the way a bucket secret is (internal/oltp/bucket.go, secret_enc),
never returned by an op. Today a mirror pulls as whoever the URL names,
usually nobody. Reading the unversioned catalog needs o on the upstream
(xfer.c: the uv-hash reply is behind g.perm.Read); fossil's stock
nobody holds o, so most public fossils answer anonymously, and the
credential is for the ones that do not and for upstreams that gate the
DAG too. y is never needed on the upstream: a mirror only reads.
The credential signs the import round only; the DAG pull goes as it
did, as the URL or the store's remembered login names it. Handing it
to the pull too (zit pull --user with ZIT_PASSWORD) would change
the identity every mirror pulls as, and is left for its own change. fossil's
check_login refuses the logins fossil init makes (anonymous,
nobody, developer, reader) whatever their password, so the
credential names a login of its own.
When the upstream does not share. fossil answers a uv-hash that
matches its own with silence too, so a silent reply is asked again with
a hash nothing matches; only when that draws no pragma either is it
recorded as the round's uv_err; the Mirror section shows "upstream
does not share its unversioned files with this account", and the DAG
pull's result stands on its own. A mirror never fails because of its
downloads. The same shape covers a file over the binding's per-file
limit: the catalog lists it, quarry skips it, the row says "not carried
(larger than this bucket's limit)".
Detaching a mirror keeps its unversioned content, as it keeps its
history. Divergence (diverged_at) is about check-ins and stays so;
a local unversioned write on a tracking mirror is refused by the gate
above, so it cannot happen silently.
Releases
Releases and deploys are different things. A release is a version of a repository's code that its owner names, builds and publishes for whoever wants it. A deploy is one operator putting some build on one machine. Tagging a release can start the release's build and publish; nothing here makes a tag, a release or an accepted landing deploy anything. quarry's own deploys stay a person's act.
Tags have no imposed syntax. Fossil projects already disagree:
fossil, sqlite, zit and pikchr tag version-X.Y.Z and move a
release tag to the newest; tcl and tk write core-9-1-0, tcllib
tcllib-2-0, lumosql v0.83, libfossil a bare 0.6.1 (counted on
zeitforge's mirrors, 2026-10-01). A repository's release workflow says
which tags are releases with a glob; quarry recognises nothing else
about a tag. The Releases page groups by the folder a workflow
publishes into, releases/<name>/, so it works whatever the tags look
like.
The tag event, amending RFC 0019's on:: a tag added to a
check-in starts the workflows whose on.tag.tags glob matches it
(Actions' pattern language, globMatch in internal/ci/on.go; default
version-* when tags: is absent). A propagating tag starts them
once, on the check-in that carries it, not on every descendant. An
environment's branch policy admits the tag's check-in only when it is
on a protected branch, so a tag on a branch nobody reviewed is not a
release with secrets.
quarry's own releases. quarry's version today is
0.1.0-beta+<check-in> stamped by scripts/build-linux.sh, nothing is
tagged, and scripts/deploy.sh builds trunk on the box. The runner
downloads for five platforms (internal/runnerdist/runnerdist.go,
Platforms) are built by the same script, verified as a set by
scripts/runner-check.sh, and served at /runner/<file> from the
directory beside the binary.
- Versions. The newest
## X.Y.Zheading inCHANGELOG.mdis the release the tree is at or working toward. A release is the check-in carrying the tagversion-X.Y.Z, with fossil's movingreleasetag moved to it.quarry versionprintsX.Y.Zwhen the built check-in carries that tag; any other check-in printsX.Y.Z-dev+<check-in>whileversion-X.Y.Zis not yet a tag anywhere, and the next patch,X.Y.(Z+1)-dev+<check-in>, once it is. The footer and the bug-report link carry the same string. Nothing is bumped after a release: the next release's heading is the bump. (As built this replaced aVERSIONfile, which had to be bumped by hand and left trunk builds after 0.2.0 reading0.2.0-dev, earlier than the release they followed.) - Changelog.
CHANGELOG.mdis already the forge's news page. Cutting a release requires its newest section to be that release's; the release job refuses otherwise. - Artifacts, under
releases/0.2.0/in the quarry repository's own unversioned files, so in its bucket:
| file | holds |
|---|---|
quarry-0.2.0-linux-amd64.tar.gz, -arm64 |
quarry, the zit it was built against (the pinned release, so the two cannot drift, RFC 0001 §2), CHANGELOG.md, LICENSE |
quarry-runner-<os>-<arch>[.exe] |
the five runner builds, same names runnerdist serves |
quarry-0.2.0-src.tar.gz |
the tagged tree |
SHA256SUMS |
one line per file above |
release-manifest.json |
zit's schema: per artifact target, sha256, size; release.version, source.checkin, release.date; omitted_targets named (macOS and Windows forge builds are omitted, the DuckDB bindings do not cross-link, as build-linux.sh records) |
The release workflow, ci/release.yml:
on:
tag:
tags: [version-*]
jobs:
release:
environment: release
steps:
- run: scripts/release.sh
env:
RELEASE_SYNC_PASSWORD: ${{ secrets.RELEASE_SYNC_PASSWORD }}
The password reaches the script as an environment variable, the only
place RFC 0019 lets a secret go, never as an argument, which every user
on the runner's machine can read. scripts/release.sh checks the
changelog's newest heading against the tag, runs
build-linux.sh for the two forge targets and the five runner targets,
packages, writes SHA256SUMS and the manifest, verifies the sums from
the files on disk, then zit uv push https://<forge>/r/quarry <file>
--as releases/0.2.0/<file> as the release account (y
on the quarry repository and nothing else; the release environment
holds its password, protected branches only). The forge writes each
file to the bucket and the group shows on /r/quarry/releases.
Retention is the job's too: it removes releases/<old>/ beyond the
newest three minor versions and the newest patch of each, with zit uv
rm URL, and the newest manifest lists what was retired. The job runs on a
runner started with --job-users, as every job holding a secret must.
Deploying a release is a person running deploy.sh --release
X.Y.Z: fetch releases/X.Y.Z/* through the forge's own /r/quarry/uv/,
verify SHA256SUMS, install quarry, zit and the runner set exactly
as the build path installs them today (keep quarry.prev, restart,
health-check, roll back on silence, restart the runner last and
delayed), no build. The trunk path (deploy.sh with no flag, and
--require-green --expect <hash>) stays as it is, for deploying what
has not been released. Rollback is deploy.sh --release
<previous>, which the releases page names beside each version, or
deploy.sh --rollback for quarry.prev when the forge is down. OLTP
migrations are additive columns, so an older binary runs against a
newer database; the index is disposable and re-folds. A release that
needs a non-additive migration says so in its changelog entry, and its
manifest carries a "cannot roll back past" mark the page shows.
/runner/ keeps serving the installed set from beside the binary:
a forge that is not zeitforge has no quarry repository to fetch from,
and the setup page's links keep working for every operator.
Build order
| Rung | Work | Waits on |
|---|---|---|
| 1 | This RFC | the owner |
| 2 | quarry catalog: the unversioned OLTP table, objects under unversioned/<repo>/ in the bound bucket, tombstones, uv.list/get/put/rm, rebuild from a bucket listing, uv.changed |
nothing |
| 3 | quarry sync front: scanRequest frames the cards; the front answers uv-hash, uvgimme, uvfile from the catalog and the bucket and forwards the rest; the stock-fossil round trip in quarry's suite |
rung 2 |
| 4 | quarry web: the listing, /uv/{name}, /uvlist and /uv/ in FossilRoute, upload and remove, the no-bucket state |
rung 2 |
| 5 | Releases page with the manifest schema and the changelog section | rung 4 |
| 6 | Mirrors: the uv switch, the credential, quarry's import round, the status lines |
rung 3 |
| 7 | (withdrawn 2026-10-02: zit keeps no local unversioned store; zit#225 closed as not planned) | |
| 8 | zit's stateless client: zit uv list|pull|push|rm URL against a fossil-speaking server |
zit#226 |
| 9 | The tag event in RFC 0019's trigger; VERSION, the stamp, scripts/release.sh, ci/release.yml, the release account, environment and job-users runner |
rungs 5, 8 |
| 10 | deploy.sh --release and --rollback, docs/running.md |
rung 9 |
| 11 | The first tagged release, version-0.2.0; zit's release skill moves its publish step to quarry and the stock fossil server is retired |
rungs 6, 10 |
Rungs 2 to 6 are quarry's alone and wait on nothing upstream: the sync
front is tested with a stock fossil binary as the client, so quarry's
half does not need zit's. zit's rungs are filed as issues on zit and
never worked around in quarry; only the release flow (rung 9) needs
them, because the release job publishes with zit.
What this is not
- Not history. A replaced file is gone; fossil says so and this RFC does not soften it. Anyone who wants the old binary keeps the old release folder, which is what retention is for.
- Not attachments. RFC 0016's files belong to a ticket, a page or a post. Unversioned files share the bucket and its binding, under their own prefix, and nothing here moves an attachment.
- Not forge storage. The forge holds the catalog and no bytes; a repository without a bucket has no unversioned files.
- Not a deploy. A tag starts a release's build and publish; no tag, release or landing deploys anything.
- Not a package registry. No index format, no per-client API, no signing beyond the sums and the manifest. A release is files with checksums at stable URLs, which is what fossil, sqlite and zit need.
- Not a rendered download page. Fossil renders
uv/download.html; quarry serves published HTML as a download and the releases page is the substitute. - Not
uv-sync. Fossil's setting makes every clone fetch the catalog unasked. A client that wants unversioned content says-u; a forge should not make every clone of sqlite carry its downloads.
Decisions
Settled by the owner on 2026-10-01, before this revision:
- Where the bytes live: the repository admin's S3-compatible bucket, RFC 0016's binding; no forge-held store and no forge quota.
- Who answers the protocol: quarry's sync front, from the bucket; zit carries only the client half.
- Without a bucket: no unversioned files until one is bound.
- Deploys: not on accepting a landing and not on a release; releases and deploys are separate, and a tag may start a release.
- Tags: no imposed syntax; each release workflow names its tags
with a glob. quarry's own are
version-X.Y.Zwith a movingreleasetag.
Testing
- Stock fossil round trip, through quarry, against an in-process S3
stand-in as the attachment tests use, with a real
fossilbinary in the suite's path: clone-uanonymously from a public repository (content arrives,uv-pull-only);uv addandsync -uas an account withouty(refused with the letter named, the DAG part not applied either); the same as an account withy(accepted, the object is in the bucket, the listing and/uv/<name>show it, the catalog names the account);uv rmandsync -u(the tombstone arrives, the listing shows it removed, a second fossil clone agrees); two writers with the same name and different mtimes (the newer wins on every side); a file over the binding's limit (refused by name); a repository with no bucket (uv-hashdraws nothing, the DAG syncs). - Gate framing.
scanRequestonuvfilewith and without flag0x0004, with a payload containing card-shaped lines (the zit#213 shape), with a short payload (fail closed), on a tracking mirror (refused as a write). - Catalog rebuild. Drop the catalog, bind the same bucket: the listing comes back from the bucket's objects and metadata, tombstones included; RFC 0001 §11's snapshot covers the listing, a download redirect and the releases page.
- Mirror import. A fixture fossil server with unversioned content:
the switch off (nothing imported); on (content and later a tombstone
arrive on the next pull,
mirror_pullrecords both); no bucket bound (switch disabled);oremoved from the upstream'snobody(the DAG pull succeeds, the status line says the upstream does not share); a credential that haso(it does again). This test also settles theo-versus-yreading of fossil's documents (see the semantics table). - Releases. A
tagevent with a matching and a non-matching glob, on a protected and an unprotected check-in.release.shagainst a scratch forge: refuses a changelog whose newest heading does not match the tag; publishes; the page groups, marks the newest, shows manifest sums that matchSHA256SUMSbyte for byte; retention removes the right folders.deploy.sh --releaseinscripts/deploy-test.sh's harness: a bad sum stops before install; a good one installs the runner set whole;--rollbackrestoresquarry.prev. - A browser pass over the listing, the no-bucket state, the upload form, a tombstone row, the Mirror section's unversioned line and the releases page, per the standing rule that UI is verified by looking.