a73x

docs/superpowers/specs/2026-08-08-release-packages-design.md

Ref:   Size: 6.7 KiB   History

# Release Packages — Design

Date: 2026-08-08
Status: Approved (brainstorm with alex)

## Purpose

Let maintainers and agents upload release artifacts (e.g. `tar.gz` builds) to the
git-collab server and distribute them over stable HTTP URLs. No package registry
semantics — just versioned files with checksums.

## Decisions (from brainstorm)

- **Upload transport:** SSH, via a new exec verb — reuses existing key auth and
  per-repo write policy. No new HTTP auth surface.
- **Versioning:** freeform version strings (e.g. `v1.2.0`); not tied to git tags.
- **Downloads:** public HTTP URLs plus a releases page in the web UI, gated by
  existing repo policies.
- **Release contents:** one or more files per version, each with a server-computed
  SHA-256. No release notes (can be added later).
- **Mutability:** re-publishing an existing version/filename is rejected unless
  `--force`; `release delete` removes a file or a whole version.

## CLI (client side)

```
git-collab release publish <version> <file>... [--force] [--remote <name>]
git-collab release list [--json] [--remote <name>]
git-collab release delete <version> [<filename>] [--remote <name>]
```

- The server is derived from the repo's SSH remote URL (`origin` by default,
  overridable with `--remote`). Both `ssh://user@host[:port]/path` and scp-style
  `user@host:path` URLs are supported. A non-SSH remote is an error.
- The client shells out to `ssh` (as git itself does), piping the file to the
  remote command's stdin for uploads.
- `publish` with multiple files uploads them sequentially and stops on first
  error, reporting which files succeeded.
- `list --json` emits machine-readable output for agent scripting.
- Exit codes: 0 on success, non-zero with the server's `error:` message on
  stderr otherwise.

## Wire protocol (SSH exec commands)

The SSH server's exec allowlist (currently `git-upload-pack`,
`git-receive-pack`) gains a `collab-release` verb family:

```
collab-release upload '<repo>' '<version>' '<filename>' [--force]
collab-release list '<repo>'
collab-release delete '<repo>' '<version>' ['<filename>']
```

- **upload:** reads the file bytes from stdin until EOF. On success prints
  `ok <sha256>\n` and exits 0. On failure prints `error: <reason>\n` and exits
  non-zero. If the target file already exists and `--force` is absent, fails
  with `error: <version>/<filename> already exists (use --force to replace)`.
- **list:** prints a JSON document to stdout:

  ```json
  {
    "versions": [
      {
        "version": "v1.2.0",
        "published": "2026-08-08T12:00:00Z",
        "files": [
          {"name": "app-x86_64.tar.gz", "size": 1048576, "sha256": "<hex>"}
        ]
      }
    ]
  }
  ```

  Versions are ordered newest-first by publish time (directory mtime).
- **delete:** with a filename, removes that file (and its `.sha256`); without,
  removes the whole version directory. Deleting the last file of a version
  removes the version directory. Missing targets are an error.

### Authorization

- `upload` and `delete` require the repo's existing **write** policy
  (same check as `git-receive-pack`).
- `list` requires **read** (same check as `git-upload-pack`).

### Validation

- `<version>` and `<filename>` must match `^[A-Za-z0-9][A-Za-z0-9._-]*$`
  (ASCII only, no leading dot, no slashes — rules out path traversal), max
  128 bytes each.
- Uploads are capped by a server config option `max_release_size` (bytes,
  default 1 GiB). An oversize stream is aborted, the temp file removed, and
  `error: file exceeds maximum release size` returned.
- Unlike git commands, the repo argument is the repo *name* resolved through
  the existing repo discovery (`repos.rs`), never a raw filesystem path.

### Atomicity

The server streams stdin to a temp file inside the repo's `collab/` dir
(same filesystem), computes SHA-256 while streaming, then atomically renames
the file and writes `<filename>.sha256` into place. A dropped connection or
failed validation never leaves a partial artifact visible. With `--force`,
the rename replaces the old file atomically.

## Server storage

```
{repo}.git/collab/releases/{version}/{filename}
{repo}.git/collab/releases/{version}/{filename}.sha256
```

Same `collab/` directory that already holds `trusted-keys`. No database or
manifest file — the filesystem is the release index. `.sha256` files contain
`<hex>  <filename>\n` (sha256sum-compatible), and are excluded from release
listings and HTTP directory listings as assets in their own right (they remain
individually downloadable).

## HTTP (distribution)

Two new routes in `src/server/http/mod.rs`:

- `GET /{repo}/releases` — HTML page listing versions newest-first, each with
  its files, sizes, and SHA-256s, linking to downloads. Uses the existing
  templates/layout. Gated by the same **UI anonymous** policy as the other
  repo pages.
- `GET /{repo}/releases/{version}/{filename}` — streams the artifact with
  `Content-Length` and `application/octet-stream` (`.sha256` companions are
  served as `text/plain`). Gated by the **anonymous_clone** HTTP policy,
  since downloads are data distribution like clone.

Path segments are validated with the same rules as upload before touching the
filesystem. Unknown repo, version, or file → 404.

## Error handling summary

| Condition | Result |
| --- | --- |
| Invalid version/filename | `error: invalid name` (SSH) / 404 (HTTP) |
| Unknown repo | error / 404 |
| No write access (upload/delete) | error, exit non-zero |
| No read access (list) | error, exit non-zero |
| File exists, no `--force` | error naming the conflict |
| Oversize upload | aborted, temp cleaned up, error |
| Dropped connection mid-upload | temp cleaned up, nothing visible |

## Testing

TDD throughout (tests first):

- **Unit:** `collab-release` command parsing (quoting, `--force`, arg counts),
  name validation (traversal attempts, dotfiles, non-ASCII, overlength).
- **End-to-end** (existing `tests/server_behavior_test.rs` style):
  - publish over SSH → files + checksums on disk, `ok <sha256>` matches.
  - publish duplicate without `--force` fails; with `--force` replaces.
  - list over SSH returns correct JSON ordering and metadata.
  - delete file / delete version / delete last file removes version dir.
  - HTTP download round-trips bytes with correct headers; releases page lists
    the version; policy-restricted repo denies anonymous download and page.
  - oversize upload rejected, no partial file left behind.
- **CLI:** publish/list/delete against a test server; `--json` output shape.

## Out of scope (deliberate)

- Release notes / markdown descriptions.
- Tying releases to git tags.
- HTTP upload endpoint or token auth.
- Signing of artifacts (checksums only; signatures can ship as ordinary
  release files, e.g. `app.tar.gz.sig`).