Skip to content
quarry Artifact [6ffa8fdfcf]
Login

Artifact [6ffa8fdfcf]

Artifact 6ffa8fdfcfdcc41c9081468842bd03953c19d5593b192064913073ec22194d62:


- 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: Draft; questions 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:` (a `tag` event, 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](#bytes))
- Supersedes: quarry ticket #19's direction (release binaries as forge-only
  attachments), see [Motivation](#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: `y` writes, `o` reads); 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](#build), needed by
  the release flow only

## Summary
[summary]: #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/`. 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 the client half: a local unversioned store and the verbs
  (`uv add|list|cat|export|rm`), and `sync -u`, `clone -u` and `uv sync
  [--pull-only]` against any fossil-speaking server, quarry included.
- **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
[motivation]: #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
[semantics]: #fossils-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
[bytes]: #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 its own prefix:

- `unversioned/<name>`: the newest content of each file, with its
  fossil `mtime` and content `hash` as object metadata;
- `unversioned/.tombstones/<name>`: an empty object whose metadata
  carries the deletion's `mtime`, which is what syncs a removal.

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]: #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. Two issues:

**zit-A, the local store and the verbs.** An `unversioned` relation in
the store with fossil's columns (`name, mtime, hash, sz, encoding,
content`), fossil's name rules, the hash policy's hash. Verbs, matching
fossil's `unversioned` help page where quarry depends on them:

| verb | does |
|---|---|
| `zit uv add FILE... [--as NAME] [--mtime T]` | add or replace; `--mtime` defaults to now |
| `zit uv list [-l] [--glob G]` | names; with `-l` mtime, hash, size, and `deleted` for a tombstone, one record per line, machine-readable |
| `zit uv cat NAME...`, `zit uv export NAME OUT` | content |
| `zit uv rm NAME...` | the tombstone (null content, size 0, new mtime) |
| `zit uv hash` | the `uv-hash` value |

`import-fossil` carries the `unversioned` table; `export` writes it back.

**zit-B, the client wire.** `zit sync -u`, `zit clone -u` and `zit uv
sync URL [--pull-only]`: send `pragma uv-hash`, read `uv-push-ok` or
`uv-pull-only` and the `uvigot` catalog, send `uvfile` and `uvgimme` by
fossil's rules (newer mtime wins, tombstones included). `--pull-only`
never sends `uvfile`; it is what a mirror uses, and it has no fossil
equivalent (fossil's nearest is `uv revert`, which overwrites local
content). Against quarry and against a stock fossil server alike.

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
[front]: #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
[letters]: #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](#releases)) is an ordinary
account with `y` on the quarry repository and nothing else.

## The web
[web]: #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](#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
[mirrors]: #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; 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, because
`zit pull` would take a password only on its command line. 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]: #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.** A `VERSION` file at the tree's root holds the version
  the tree is working toward, `0.2.0`. A release is the check-in
  carrying the tag `version-0.2.0`, with fossil's moving `release` tag
  moved to it. `quarry version` prints `0.2.0` when the built check-in
  carries the tag matching `VERSION`, else `0.2.0-dev+<check-in>`; the
  footer and the bug-report link carry the same string. The beta
  suffix retires at the first tag.
- **Changelog.** `CHANGELOG.md` is already the forge's news page.
  Cutting a release requires a `## 0.2.0` heading in it; 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`:

```yaml
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 `VERSION`
against the tag and `CHANGELOG.md` for the heading, 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 add --as releases/0.2.0/<file>` into a
scratch clone and `zit sync -u` to the forge 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`, 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
[build]: #build-order

| Rung | Work | Waits on |
|---|---|---|
| 1 | This RFC | the owner |
| 2 | quarry catalog: the `unversioned` OLTP table, objects under `unversioned/` 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 | zit-A: the local store and `uv add|list|cat|export|rm|hash`; `import-fossil` and `export` carry the table | a zit issue |
| 8 | zit-B: `sync -u`, `clone -u`, `uv sync [--pull-only]` against a fossil-speaking server | a zit issue; rung 7 |
| 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]: #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
[decisions]: #decisions

Settled by the owner on 2026-10-01, before this revision:

1. **Where the bytes live:** the repository admin's S3-compatible
   bucket, RFC 0016's binding; no forge-held store and no forge quota.
2. **Who answers the protocol:** quarry's sync front, from the bucket;
   zit carries only the client half.
3. **Without a bucket:** no unversioned files until one is bound.
4. **Deploys:** not on accepting a landing and not on a release;
   releases and deploys are separate, and a tag may start a release.
5. **Tags:** no imposed syntax; each release workflow names its tags
   with a glob. quarry's own are `version-X.Y.Z` with a moving
   `release` tag.

## Testing
[testing]: #testing

- **Stock fossil round trip, through quarry**, against an in-process S3
  stand-in as the attachment tests use, with a real `fossil` binary in
  the suite's path: clone `-u` anonymously from a public repository
  (content arrives, `uv-pull-only`); `uv add` and `sync -u` as an
  account without `y` (refused with the letter named, the DAG part not
  applied either); the same as an account with `y` (accepted, the object
  is in the bucket, the listing and `/uv/<name>` show it, the catalog
  names the account); `uv rm` and `sync -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-hash` draws nothing, the DAG syncs).
- **Gate framing.** `scanRequest` on `uvfile` with and without flag
  `0x0004`, 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_pull` records both); no bucket bound
  (switch disabled); `o` removed from the upstream's `nobody` (the DAG
  pull succeeds, the status line says the upstream does not share); a
  credential that has `o` (it does again). This test also settles the
  `o`-versus-`y` reading of fossil's documents (see the semantics
  table).
- **Releases.** A `tag` event with a matching and a non-matching glob,
  on a protected and an unprotected check-in. `release.sh` against a
  scratch forge: refuses a missing changelog heading and a `VERSION`
  that does not match the tag; publishes; the page groups, marks the
  newest, shows manifest sums that match `SHA256SUMS` byte for byte;
  retention removes the right folders. `deploy.sh --release` in
  `scripts/deploy-test.sh`'s harness: a bad sum stops before install; a
  good one installs the runner set whole; `--rollback` restores
  `quarry.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.