detached a0d5142

Merge 5dbd27fc45163a9fccc5f6cf45c55ed6935ad7d3 into f61e6a68e9c866c042a8c5e4f64cae2b1290a363

commit
a0d51422a46636ad3ad53cb0ff4ce047e770b346
author
ermite · · 2026-10-21 18:30 UTC
merge of
f61e6a6 + 5dbd27f · diff against the first parent
signature
unsigned
149 files · +17035 -809
M .hexcheck.yaml +12 -2
@@ -21,6 +21,11 @@ components:
2121 role: adapter
2222 paths:
2323 - internal/clientlimit/**
24+ httprange:
25+ # Byte-range selection: infrastructure shared by the HTTP adapters.
26+ role: adapter
27+ paths:
28+ - internal/httprange/**
2429 entrypoints:
2530 # cmd/ermite only calls app.Run.
2631 role: entrypoint
@@ -68,6 +73,11 @@ mocking:
6873 generatedMockNamePatterns:
6974 - '{{Interface}}'
7075 allow:
76+ # Secret-source exclusivity and file decoding are configuration validation,
77+ # not domain policy; config is intentionally a composition-root helper.
78+ - rule: suspicious-business-logic-in-adapter
79+ path: internal/config/config.go
80+ reason: validates explicit environment and secret-file sources at startup
7181 # clientlimit is a shared infrastructure library, not a sibling adapter: the
7282 # HTTP adapters that rate-limit clients import it by design.
7383 - rule: no-adapter-to-adapter-imports
@@ -75,7 +85,7 @@ allow:
7585 reason: rate-limits Git clients with internal/clientlimit
7686 - rule: no-adapter-to-adapter-imports
7787 path: internal/adapters/githubapi/**
78- reason: rate-limits the clients of the REST subset with internal/clientlimit
88+ reason: rate-limits the clients of the REST subset with internal/clientlimit and selects byte ranges with internal/httprange
7989 - rule: no-adapter-to-adapter-imports
8090 path: internal/adapters/oidchttp/**
8191 reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit
@@ -84,4 +94,4 @@ allow:
8494 reason: rate-limits API clients with internal/clientlimit
8595 - rule: no-adapter-to-adapter-imports
8696 path: internal/adapters/web/**
87- reason: rate-limits login attempts with internal/clientlimit
97+ reason: rate-limits login attempts with internal/clientlimit and selects byte ranges with internal/httprange
M README.md +1 -0
@@ -18,6 +18,7 @@
1818 - **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container
1919 - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents
2020 - **Safe for agents**: an agent token can only push its own branches; you review and merge
21+- **[Releases](docs/releases.md)** with a GitHub-compatible API, CLI and web downloads; assets and CI artifacts on [disk or S3-compatible storage](docs/storage.md)
2122 - Git over HTTPS, web interface with live updates
2223
2324 Status: early development.
M compose.yaml +39 -0
@@ -24,13 +24,52 @@ services:
2424 ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-0123456789
2525 ERMITE_LOG_FORMAT: console
2626 ERMITE_LOG_LEVEL: debug
27+ # Empty bucket keeps the disk backend, even with the s3 profile active.
28+ ERMITE_S3_BUCKET: ${ERMITE_S3_BUCKET:-}
29+ ERMITE_S3_ENDPOINT: ${ERMITE_S3_ENDPOINT:-}
30+ ERMITE_S3_REGION: ${ERMITE_S3_REGION:-}
31+ ERMITE_S3_ACCESS_KEY_ID: ${ERMITE_S3_ACCESS_KEY_ID:-}
32+ ERMITE_S3_SECRET_ACCESS_KEY: ${ERMITE_S3_SECRET_ACCESS_KEY:-}
33+ ERMITE_S3_FORCE_PATH_STYLE: ${ERMITE_S3_FORCE_PATH_STYLE:-0}
34+ ERMITE_S3_PREFIX: ${ERMITE_S3_PREFIX:-}
2735 volumes:
2836 - .:/src
2937 - go-mod:/go/pkg/mod
3038 - go-build:/root/.cache/go-build
3139 - data:/data
3240
41+ minio:
42+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
43+ profiles: [s3]
44+ command: server /data --console-address :9001
45+ environment:
46+ MINIO_ROOT_USER: minioadmin
47+ MINIO_ROOT_PASSWORD: minioadmin
48+ ports:
49+ - "127.0.0.1:9000:9000"
50+ - "127.0.0.1:9001:9001"
51+ volumes:
52+ - minio-data:/data
53+ healthcheck:
54+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
55+ interval: 2s
56+ timeout: 2s
57+ retries: 30
58+
59+ # Development provisioning only. The ermite process never creates buckets.
60+ minio-init:
61+ image: minio/mc:RELEASE.2025-08-13T08-35-41Z
62+ profiles: [s3]
63+ depends_on:
64+ minio:
65+ condition: service_healthy
66+ entrypoint: ["/bin/sh", "-c"]
67+ command:
68+ - "mc alias set dev http://minio:9000 minioadmin minioadmin && mc mb --ignore-existing dev/ermite && mc mb --ignore-existing dev/ermite-test"
69+ restart: "no"
70+
3371 volumes:
72+ minio-data:
3473 go-mod:
3574 go-build:
3675 data:
M docs/ci-artifacts.md +2 -1
@@ -13,7 +13,8 @@ The run page lists them with download links.
1313
1414 - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.
1515 - Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.
16-- Stored under `$ERMITE_DATA_DIR/ci/artifacts`. Removed when they expire, or with their run or repository.
16+- Contents use the shared [blob store](storage.md): `$ERMITE_DATA_DIR/blobs/objects/ci/` by default, or the configured S3 bucket/prefix. Metadata stays in kv. Removed when they expire, or with their run or repository.
17+- Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until expiry, with no copy or migration. Keep that tree until its last artifact expires. Changing backends does not move blob-backed artifacts; keep the backend stable while they are live.
1718 - An upload not finished when its job ends is dropped.
1819
1920 ## Limits
M docs/cli.md +45 -0
@@ -96,6 +96,51 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9696
9797 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
9898
99+## Releases
100+
101+Releases need a server advertising the `releases` feature. Published releases follow
102+repository visibility; drafts are visible only to release writers. Writes need
103+`release:write` (included in the `cli` token preset); branch-limited tokens cannot
104+write releases. On an older server the preset drops this scope with a note, while
105+an explicit `--scope release:write` fails before creating a token.
106+
107+```sh
108+ermite release create <repo> <tag> [--title T] [--notes TEXT | --notes-file F] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]]… [--json]
109+ermite release list <repo> [--limit N] [--json]
110+ermite release view <repo> <tag|id|latest> [--json | --notes]
111+ermite release edit <repo> <tag|id> [--title T] [--notes TEXT | --notes-file F] [--tag NEW] [--target REF] [--draft | --publish] [--prerelease | --no-prerelease] [--latest]
112+ermite release delete <repo> <tag|id> [--yes]
113+ermite release upload <repo> <tag|id> FILE[#LABEL]… [--clobber] [--content-type T]
114+ermite release download <repo> <tag|id|latest> [--pattern GLOB]… [--name N]… [--dir D]
115+```
116+
117+All-digit selectors are release ids; other selectors are tags. `latest` selects
118+the latest release for view/download. `--latest` pins a release as latest.
119+Non-draft releases require an existing tag, or `--target` to create one; an
120+existing tag is never moved. `--notes-file -` reads Markdown from standard input.
121+Edit changes only supplied fields: `--title=''` and `--notes=''` clear them;
122+`--publish` and `--no-prerelease` clear the corresponding flags.
123+
124+Create uploads all `--asset` files, then prints the release web URL and id.
125+Each asset's name is the file basename; an optional `#LABEL` sets its display
126+label (quote it). Upload accepts multiple file arguments, including shell-expanded
127+globs. For create, repeat `--asset` for each file. Content type is detected from
128+the extension, or sniffed from the first bytes; upload's `--content-type` overrides
129+it. A duplicate name fails with a hint to use `--clobber`. A failed upload after
130+create leaves the release in place and reports its id.
131+
132+List defaults to 30 entries and follows server pagination up to `--limit`.
133+View shows fields, notes and an asset table; `--notes` prints Markdown alone.
134+Create, list and view support stable JSON (fields are listed in their help).
135+Delete removes the release and assets, not its Git tag; it asks for confirmation
136+only on a terminal unless `--yes` is given.
137+
138+Download defaults to all assets, into `--dir` (default `.`). Repeat `--name` or
139+quote and repeat `--pattern` to select a union of names and globs. Files are never
140+overwritten. One interrupted stream is resumed automatically from the bytes
141+already received; size and SHA-256 digest are verified. Failed or mismatching
142+downloads are removed. Resume is within the command, not across invocations.
143+
99144 ## Output
100145
101146 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
M docs/configuration.md +29 -1
@@ -60,10 +60,38 @@ head -c 32 /dev/urandom | base64
6060 - The server refuses to start with a different key than the one the secrets were sealed with.
6161 - Losing the key loses every secret. A backup without the key reveals none.
6262
63+## Blob storage and releases
64+
65+| Variable | Default | Notes |
66+|---|---|---|
67+| `ERMITE_S3_BUCKET` | empty | selects S3 when nonempty; otherwise disk under `$ERMITE_DATA_DIR/blobs` |
68+| `ERMITE_S3_ENDPOINT` | empty | http(s) endpoint; empty uses AWS region resolution; no credentials, path, query or fragment |
69+| `ERMITE_S3_REGION` | empty | required with a bucket, including custom endpoints |
70+| `ERMITE_S3_ACCESS_KEY_ID` | empty | explicit key, required with a bucket |
71+| `ERMITE_S3_SECRET_ACCESS_KEY` | empty | explicit secret, required with a bucket |
72+| `ERMITE_S3_FORCE_PATH_STYLE` | `0` | `0`/`1` or `false`/`true`; `1` for MinIO without wildcard DNS |
73+| `ERMITE_S3_PREFIX` | empty | ASCII key prefix, normalized to a trailing `/`; no empty, `.` or `..` segments |
74+| `ERMITE_RELEASE_ASSET_MAX` | `2GiB` | one release asset |
75+| `ERMITE_RELEASE_ASSETS_MAX` | `1000` | positive integer, per release |
76+| `ERMITE_RELEASE_QUOTA` | `20GiB` | per repository, including uploads in progress |
77+
78+Sizes accept bytes or `KiB`, `MiB`, `GiB`, `TiB`, from 1 MiB to 1 PiB.
79+See [storage](storage.md) for AWS, Scaleway, R2, MinIO, permissions and backups,
80+and [releases](releases.md) for publishing.
81+
82+### Secret files
83+
84+`ERMITE_ADMIN_TOKEN`, `ERMITE_SECRETS_KEY`, `ERMITE_S3_ACCESS_KEY_ID` and
85+`ERMITE_S3_SECRET_ACCESS_KEY` each support a `_FILE` variant, for example
86+`ERMITE_ADMIN_TOKEN_FILE=/run/secrets/admin-token`. Set either the direct value
87+or its file, never both. Files are read once at startup; trailing CR/LF is
88+removed. Empty or unreadable files fail validation. Secret contents and file
89+paths are not included in file-resolution errors. No AWS credential chain is used.
90+
6391 ## Runtime files
6492
6593 The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run/`. The socket path must stay under about 100 bytes, so keep `ERMITE_DATA_DIR` short.
6694
6795 ## Storage schema
6896
69-This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
97+This build uses storage schema 6. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A docs/releases.md +99 -0
@@ -0,0 +1,99 @@
1+# Releases
2+
3+A release binds a Git tag to a title, Markdown notes and named assets. There is
4+one release per tag. Published releases follow repository visibility; drafts
5+are visible only to writers. The web Releases tab is read-only.
6+
7+```sh
8+ermite release create app v1.0.0 --target main --title 'Version 1' --notes-file notes.md --asset dist/app
9+ermite release list app --json
10+ermite release view app latest
11+ermite release edit app v1.0.0 --notes 'Updated notes'
12+ermite release upload app v1.0.0 dist/app --clobber
13+ermite release download app latest --pattern 'app*' --dir downloads
14+ermite release delete app v1.0.0 --yes
15+```
16+
17+Create/edit accept `--draft`, `--prerelease`, `--latest`, `--target` and notes.
18+Use `edit --publish` to publish a draft, `--no-prerelease` to clear that flag.
19+An existing tag never moves; `--target` creates a missing tag. A draft can name a
20+missing tag. Deleting a tag turns its release into a draft and keeps its assets;
21+recreating the tag does not republish it. Deleting a release does not delete the tag.
22+
23+`latest` is the newest published non-prerelease by creation time and id, unless
24+pinned with `--latest` / REST `make_latest: "true"`. Drafting or deleting the
25+pinned release clears the pin. Asset names are unique within a release, 1–255
26+characters, without slashes, backslashes or control characters. CLI `--clobber`
27+replaces a same-named asset; REST publishers delete it then upload again.
28+
29+Writes require `release:write` (included in the `cli` preset), or a CI job token
30+with `permissions: contents: write` for that repository. Branch-limited tokens
31+cannot write releases. Read access to a private repository needs `repo:read`;
32+unreadable releases, including drafts, answer not found. Older servers without
33+the `releases` feature are refused by the CLI.
34+
35+## Limits and storage
36+
37+| Variable | Default |
38+|---|---|
39+| `ERMITE_RELEASE_ASSET_MAX` | `2GiB` per asset |
40+| `ERMITE_RELEASE_ASSETS_MAX` | `1000` per release |
41+| `ERMITE_RELEASE_QUOTA` | `20GiB` per repository |
42+
43+Quota includes uploads in progress. Exceeding limits gives REST 422 or RPC
44+`resource_exhausted`. Assets use the shared [blob store](storage.md), disk by
45+default or S3. Downloads pass through ermite, never redirect to the bucket, and
46+support single byte ranges with 206, Content-Length and Content-Disposition.
47+Stable links:
48+
49+- `/<repo>/releases/download/<tag>/<asset>`
50+- `/<repo>/releases/latest/download/<asset>`
51+
52+## GitHub-compatible publishers
53+
54+REST is at `<public URL>/-/github/api`, uploads at
55+`<public URL>/-/github/api/uploads`. Use the external `ERMITE_PUBLIC_URL`, not
56+the S3 endpoint. Stored-token publishers need `release:write` and, for private
57+repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
58+default).
59+
60+GoReleaser configuration (replace the host and repository):
61+
62+```yaml
63+version: 2
64+github_urls:
65+ api: https://git.example.org/-/github/api/
66+ upload: https://git.example.org/-/github/api/uploads/
67+ download: https://git.example.org/
68+release:
69+ github:
70+ owner: owner
71+ name: app
72+```
73+
74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
75+
76+On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
77+`GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
78+
79+```yaml
80+name: release
81+on:
82+ push:
83+ tags: ['v*']
84+permissions:
85+ contents: write
86+jobs:
87+ publish:
88+ runs-on: ubuntu-latest
89+ steps:
90+ - uses: actions/checkout@v4
91+ - run: mkdir -p dist && printf 'hello\n' > dist/app.txt
92+ - uses: softprops/action-gh-release@v2
93+ with:
94+ files: dist/*
95+ body: Release built by CI
96+```
97+
98+`contents: read` is insufficient and gets 403. Automatic release-note generation,
99+discussions, reactions, immutability and attestations are not supported.
A docs/storage.md +92 -0
@@ -0,0 +1,92 @@
1+# Blob storage
2+
3+Release assets and CI artifact contents share one blob store. Metadata stays in
4+`$ERMITE_DATA_DIR/kv`; Git repositories and CI logs stay on local disk.
5+
6+With no `ERMITE_S3_BUCKET`, objects live under
7+`$ERMITE_DATA_DIR/blobs/objects/`. Directories are private (0700), files 0600.
8+Back up the entire data directory while the server is stopped. With S3, back up
9+both the data directory and the bucket prefix consistently.
10+
11+## S3-compatible storage
12+
13+Create a bucket yourself, then configure ermite. All examples use explicit keys:
14+
15+```sh
16+export ERMITE_S3_BUCKET=forge-assets
17+export ERMITE_S3_PREFIX=ermite/blobs
18+export ERMITE_S3_ACCESS_KEY_ID_FILE=/run/secrets/s3-id
19+export ERMITE_S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3-secret
20+```
21+
22+Select the endpoint and signing region for your provider:
23+
24+| Service | `ERMITE_S3_ENDPOINT` | `ERMITE_S3_REGION` | `ERMITE_S3_FORCE_PATH_STYLE` |
25+|---|---|---|---|
26+| AWS S3 | unset (SDK resolves from region) | bucket region, e.g. `eu-west-1` | `0` |
27+| Scaleway | `https://s3.fr-par.scw.cloud` | `fr-par` | `0` |
28+| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` | `0` |
29+| MinIO (compose network) | `http://minio:9000` | `us-east-1` | `1` |
30+
31+Any S3-compatible endpoint implementing the subset below can be used. There is
32+no ambient AWS credential chain: `AWS_*`, shared credential files and instance
33+roles are not consulted. Region and both keys are required with a bucket.
34+Each key supports `_FILE`; direct value and file are mutually exclusive.
35+
36+On startup, ermite probes the store (disk write probe; S3 `HeadBucket`, falling
37+back to `ListObjectsV2` with one key on 403). Failure stops startup and names
38+`ERMITE_S3_BUCKET`. Success logs `blob store ready`, never credential values.
39+The S3 probe checks reachability/read permission, not every write permission.
40+
41+Grant access to objects under the configured prefix: put, get/head, delete,
42+list, and create/upload/complete/abort/list multipart uploads. On AWS these are
43+`s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`,
44+`s3:AbortMultipartUpload`, `s3:ListMultipartUploadParts` on the object resource,
45+and `s3:ListBucket`, `s3:ListBucketMultipartUploads` on the bucket resource.
46+Constrain listing to the prefix where supported. ermite never creates buckets or
47+calls bucket administration APIs (policy, ACL, tagging, lifecycle, versioning).
48+The client uses checksums only when required, not automatic CRC32 on every PUT.
49+
50+Downloads are relayed through ermite with repository authorization and Range
51+support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts.
52+The daily sweep removes unreferenced blobs and unfinished uploads older than
53+24 hours. Release metadata with a missing blob is removed only after two sweeps.
54+MinIO lists unfinished uploads only for an exact object key, so the sweep does
55+not see them there; MinIO removes them itself (`stale_uploads_expiry`, 24 hours
56+by default).
57+
58+## Backend changes
59+
60+Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until
61+expiry; there is no automatic copy. New artifacts and all release assets use the
62+currently selected backend. **Do not switch backends with live blob-backed data**:
63+records identify blobs, not their backend. Switching disk to S3, changing bucket
64+or prefix, or switching back makes earlier blob-backed contents unavailable.
65+Keep the selected backend stable, or arrange a complete offline copy yourself.
66+
67+## Development compose
68+
69+Disk (no MinIO services):
70+
71+```sh
72+ERMITE_S3_BUCKET= docker compose up
73+```
74+
75+MinIO provisioning must finish before the forge starts its probe:
76+
77+```sh
78+docker compose --profile s3 up -d minio
79+docker compose --profile s3 run --rm minio-init
80+ERMITE_S3_BUCKET=ermite ERMITE_S3_ENDPOINT=http://minio:9000 \
81+ERMITE_S3_REGION=us-east-1 ERMITE_S3_ACCESS_KEY_ID=minioadmin \
82+ERMITE_S3_SECRET_ACCESS_KEY=minioadmin ERMITE_S3_FORCE_PATH_STYLE=1 \
83+ERMITE_S3_PREFIX=dev/ docker compose --profile s3 up ermite
84+```
85+
86+Console: <http://localhost:9001>, development credentials `minioadmin` /
87+`minioadmin`. The init service creates `ermite` and `ermite-test`; only that
88+provisioning service has bucket creation duties. For Scaleway or another external
89+service, use the same environment variables with its existing bucket, endpoint,
90+region and keys, and `docker compose up ermite` without the profile. These are
91+development settings; never use the placeholder admin token or MinIO keys in
92+production.
M docs/tokens.md +9 -0
@@ -15,6 +15,14 @@ The secret is printed once. Presets and `--scope` add up (`issue:write` lets a t
1515 | `ci` | `repo:read` |
1616 | `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |
1717
18+## Release publishing
19+
20+`release:write` permits creating, editing and deleting releases and their assets;
21+it is included in the `cli` preset, not `agent` or `ci`. Private repository reads
22+also need `repo:read`. Branch-limited tokens cannot write releases, even with
23+that scope. CI jobs publish using their job token with `contents: write` under
24+`permissions`, not a stored-token scope. See [releases](releases.md).
25+
1826 ## Branch-limited tokens
1927
2028 For an AI agent that should propose changes, not publish them:
@@ -38,6 +46,7 @@ The token **cannot**:
3846
3947 - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;
4048 - create, delete or configure a repository;
49+- create, edit or delete releases or upload assets;
4150 - open web sessions or get owner rights, even with every scope;
4251 - manage runners, even with `runner:register`;
4352 - get trusted CI runs (no secrets). See [CI trust](ci.md#trust).
M gen/ermite/storage/ci_artifact.pb.go +15 -5 generated file: collapsed
A gen/ermite/storage/release.pb.go +463 -0 generated file: collapsed
M go.mod +16 -1
@@ -13,18 +13,22 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.15.0
16+ bnema.dev/ermite-api v0.16.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
2020 github.com/ProtonMail/go-crypto v1.4.1
2121 github.com/a-h/templ v0.3.1020
2222 github.com/alecthomas/chroma/v2 v2.27.0
23+ github.com/aws/aws-sdk-go-v2 v1.47.3
24+ github.com/aws/aws-sdk-go-v2/credentials v1.20.9
25+ github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
2326 github.com/bnema/kvconf v0.1.2
2427 github.com/bnema/zerowrap v1.4.1
2528 github.com/descope/virtualwebauthn v1.0.5
2629 github.com/go-webauthn/webauthn v0.18.2
2730 github.com/golang-jwt/jwt/v5 v5.3.1
31+ github.com/johannesboyne/gofakes3 v1.2.0
2832 github.com/spf13/cobra v1.10.2
2933 github.com/stretchr/testify v1.12.1
3034 github.com/yuin/goldmark v1.8.6
@@ -61,6 +65,15 @@ require (
6165 github.com/air-verse/air v1.67.4 // indirect
6266 github.com/andybalholm/brotli v1.2.0 // indirect
6367 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
68+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
69+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
70+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
71+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
72+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
73+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
74+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
75+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
76+ github.com/aws/smithy-go v1.28.5 // indirect
6477 github.com/bep/godartsass/v2 v2.5.0 // indirect
6578 github.com/bep/golibsass v1.2.0 // indirect
6679 github.com/bits-and-blooms/bitset v1.24.5 // indirect
@@ -134,6 +147,7 @@ require (
134147 github.com/rs/cors v1.11.1 // indirect
135148 github.com/rs/zerolog v1.35.1 // indirect
136149 github.com/russross/blackfriday/v2 v2.1.0 // indirect
150+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
137151 github.com/segmentio/asm v1.2.1 // indirect
138152 github.com/segmentio/encoding v0.5.4 // indirect
139153 github.com/sirupsen/logrus v1.10.2 // indirect
@@ -159,6 +173,7 @@ require (
159173 go.opentelemetry.io/otel v1.46.0 // indirect
160174 go.opentelemetry.io/otel/metric v1.46.0 // indirect
161175 go.opentelemetry.io/otel/trace v1.46.0 // indirect
176+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
162177 go.uber.org/multierr v1.11.0 // indirect
163178 go.uber.org/zap v1.28.0 // indirect
164179 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
M go.sum +40 -2
@@ -1,7 +1,7 @@
11 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
22 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.15.0 h1:FCTu0iBhx+eXAUnO2U1er3KEzHG/nlfDoM4D/2Lrqv8=
4-bnema.dev/ermite-api v0.15.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-api v0.16.0 h1:o2lVIt9ftFkMLSPwW4ZKFIsOpPMAFJdvMtvZ8+7mrzM=
4+bnema.dev/ermite-api v0.16.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
66 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
77 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
@@ -72,6 +72,32 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
7272 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
7373 github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
7474 github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
75+github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk=
76+github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A=
77+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI=
78+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ=
79+github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0=
80+github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I=
81+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 h1:Zy6Tme1AA13kX8x3CnkHx5cqdGWGaj/anwOiWGnA0Xo=
82+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12/go.mod h1:ql4uXYKoTM9WUAUSmthY4AtPVrlTBZOvnBJTiCUdPxI=
83+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0=
84+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE=
85+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM=
86+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4=
87+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4=
88+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
89+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
90+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
91+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
92+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
93+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
94+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
95+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
96+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
97+github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA=
98+github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys=
99+github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k=
100+github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
75101 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
76102 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
77103 github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
@@ -128,6 +154,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
128154 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
129155 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
130156 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
157+github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
158+github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
131159 github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
132160 github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
133161 github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
@@ -250,6 +278,8 @@ github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc
250278 github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
251279 github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88=
252280 github.com/jhump/protoreflect/v2 v2.0.0-beta.2/go.mod h1:4tnOYkB/mq7QTyS3YKtVtNrJv4Psqout8HA1U+hZtgM=
281+github.com/johannesboyne/gofakes3 v1.2.0 h1:I9VEzPWvvAUAGzDlhYFoZjF0AXMlkcEyZlmBwiI6Oms=
282+github.com/johannesboyne/gofakes3 v1.2.0/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
253283 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
254284 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
255285 github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
@@ -360,6 +390,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
360390 github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
361391 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
362392 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
393+github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
394+github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
363395 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
364396 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
365397 github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
@@ -413,6 +445,8 @@ github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
413445 github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
414446 github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
415447 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
448+go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
449+go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
416450 go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI=
417451 go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac=
418452 go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE=
@@ -435,6 +469,8 @@ go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3
435469 go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
436470 go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
437471 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
472+go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
473+go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
438474 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
439475 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
440476 go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -476,6 +512,8 @@ google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
476512 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
477513 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
478514 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
515+gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
516+gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
479517 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
480518 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
481519 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
A internal/adapters/blobdisk/blobdisk.go +572 -0
@@ -0,0 +1,572 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobdisk stores objects under root/objects, temporary writes under
4+// root/tmp, and unfinished multipart uploads under root/multipart. Files are
5+// 0600 in 0700 directories; completed objects appear by synced atomic rename.
6+package blobdisk
7+
8+import (
9+ "context"
10+ "crypto/rand"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "io/fs"
18+ "os"
19+ "path/filepath"
20+ "sort"
21+ "strconv"
22+ "strings"
23+ "time"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+ "github.com/bnema/zerowrap"
28+)
29+
30+// Store is the disk implementation of ports.BlobStore.
31+type Store struct{ root string }
32+
33+var _ ports.BlobStore = (*Store)(nil)
34+
35+// Dir returns the blob root under dataDir.
36+func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") }
37+
38+// New returns a disk store without performing I/O.
39+func New(root string) *Store { return &Store{root: root} }
40+func (s *Store) object(key string) string {
41+ return filepath.Join(s.root, "objects", filepath.FromSlash(key))
42+}
43+func valid(key string) error {
44+ if !ports.ValidBlobKey(key) {
45+ return domain.ErrInvalid
46+ }
47+ return nil
48+}
49+func translate(err error) error {
50+ if errors.Is(err, fs.ErrNotExist) {
51+ return domain.ErrNotFound
52+ }
53+ return err
54+}
55+
56+// tmpGrace is how long leftovers in root/tmp (and malformed multipart dirs)
57+// are kept before reclamation.
58+const tmpGrace = time.Hour
59+
60+// now is the clock used for reclamation; tests may replace it.
61+var now = time.Now
62+
63+// syncDir fsyncs a directory; tests may replace it to record calls.
64+var syncDir = func(path string) error {
65+ f, e := os.Open(path)
66+ if e != nil {
67+ return e
68+ }
69+ return errors.Join(f.Sync(), f.Close())
70+}
71+
72+// mkdirAllSync creates dir (under root) with 0700, fsyncing the parent of
73+// every directory it creates so the new entries are durable.
74+func mkdirAllSync(root, dir string) error {
75+ var missing []string
76+ for d := filepath.Clean(dir); d != filepath.Clean(root) && d != filepath.Dir(d); d = filepath.Dir(d) {
77+ if fi, e := os.Stat(d); e == nil {
78+ if !fi.IsDir() {
79+ return fmt.Errorf("%s: not a directory", d)
80+ }
81+ break
82+ } else if !errors.Is(e, fs.ErrNotExist) {
83+ return e
84+ }
85+ missing = append(missing, d)
86+ }
87+ for i := len(missing) - 1; i >= 0; i-- {
88+ if e := os.Mkdir(missing[i], 0700); e != nil && !errors.Is(e, fs.ErrExist) {
89+ return e
90+ }
91+ if e := syncDir(filepath.Dir(missing[i])); e != nil {
92+ return e
93+ }
94+ }
95+ return nil
96+}
97+
98+// reclaimTmp removes entries (files or directories) in root/tmp whose mtime
99+// is older than olderThan.
100+func (s *Store) reclaimTmp(ctx context.Context, olderThan time.Duration) error {
101+ dir := filepath.Join(s.root, "tmp")
102+ entries, e := os.ReadDir(dir)
103+ if errors.Is(e, fs.ErrNotExist) {
104+ return nil
105+ }
106+ if e != nil {
107+ return e
108+ }
109+ cutoff := now().Add(-olderThan)
110+ var errs []error
111+ for _, d := range entries {
112+ if e = ctx.Err(); e != nil {
113+ return e
114+ }
115+ fi, e := d.Info()
116+ if e != nil || !fi.ModTime().Before(cutoff) {
117+ continue
118+ }
119+ if e = os.RemoveAll(filepath.Join(dir, d.Name())); e != nil {
120+ errs = append(errs, e)
121+ continue
122+ }
123+ logEntry := zerowrap.FromCtx(ctx)
124+ logEntry.Info().Str("entry", d.Name()).Msg("blobdisk: reclaimed stale tmp entry")
125+ }
126+ return errors.Join(errs...)
127+}
128+
129+type ctxReader struct {
130+ ctx context.Context
131+ r io.Reader
132+}
133+
134+func (r ctxReader) Read(p []byte) (int, error) {
135+ if e := r.ctx.Err(); e != nil {
136+ return 0, e
137+ }
138+ return r.r.Read(p)
139+}
140+func (s *Store) init(ctx context.Context) error {
141+ if e := ctx.Err(); e != nil {
142+ return e
143+ }
144+ for _, d := range []string{"objects", "tmp", "multipart"} {
145+ if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil {
146+ return e
147+ }
148+ }
149+ return nil
150+}
151+
152+// Check verifies that the root is writable.
153+func (s *Store) Check(ctx context.Context) error {
154+ logEntry := zerowrap.FromCtx(ctx)
155+ logEntry.Debug().Msg("blobdisk: Check")
156+ if e := s.init(ctx); e != nil {
157+ return e
158+ }
159+ if e := s.reclaimTmp(ctx, tmpGrace); e != nil {
160+ logEntry.Warn().Err(e).Msg("blobdisk: tmp reclamation failed")
161+ }
162+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*")
163+ if e != nil {
164+ return e
165+ }
166+ return errors.Join(f.Close(), os.Remove(f.Name()))
167+}
168+
169+// Put atomically replaces key with exactly size bytes.
170+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
171+ logEntry := zerowrap.FromCtx(ctx)
172+ logEntry.Debug().Msg("blobdisk: Put")
173+ if e := valid(key); e != nil {
174+ return ports.BlobInfo{}, e
175+ }
176+ if size < 0 {
177+ return ports.BlobInfo{}, domain.ErrInvalid
178+ }
179+ if e := s.init(ctx); e != nil {
180+ return ports.BlobInfo{}, e
181+ }
182+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*")
183+ if e != nil {
184+ return ports.BlobInfo{}, e
185+ }
186+ defer func() { _ = os.Remove(f.Name()) }()
187+ h := sha256.New()
188+ n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r})
189+ if e == nil && n != size {
190+ e = domain.ErrInvalid
191+ }
192+ if e == nil {
193+ e = ctx.Err()
194+ }
195+ if e == nil {
196+ e = f.Sync()
197+ }
198+ e = errors.Join(e, f.Close())
199+ if e != nil {
200+ return ports.BlobInfo{}, e
201+ }
202+ path := s.object(key)
203+ if e = mkdirAllSync(filepath.Join(s.root, "objects"), filepath.Dir(path)); e == nil {
204+ e = os.Rename(f.Name(), path)
205+ }
206+ if e == nil {
207+ e = syncDir(filepath.Dir(path))
208+ }
209+ if e != nil {
210+ return ports.BlobInfo{}, e
211+ }
212+ info, e := s.Stat(ctx, key)
213+ info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
214+ return info, e
215+}
216+
217+// Stat returns object metadata.
218+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
219+ if e := valid(key); e != nil {
220+ return ports.BlobInfo{}, e
221+ }
222+ if e := ctx.Err(); e != nil {
223+ return ports.BlobInfo{}, e
224+ }
225+ f, e := os.Stat(s.object(key))
226+ if e != nil {
227+ return ports.BlobInfo{}, translate(e)
228+ }
229+ if !f.Mode().IsRegular() {
230+ return ports.BlobInfo{}, domain.ErrNotFound
231+ }
232+ return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil
233+}
234+
235+type limitedCloser struct {
236+ io.Reader
237+ io.Closer
238+}
239+
240+// Open reads an object range.
241+//
242+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port
243+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
244+ info, e := s.Stat(ctx, key)
245+ if e != nil {
246+ return nil, info, e
247+ }
248+ if offset < 0 {
249+ return nil, info, domain.ErrInvalid
250+ }
251+ if offset >= info.Size {
252+ return nil, info, domain.ErrOutOfRange
253+ }
254+ f, e := os.Open(s.object(key))
255+ if e != nil {
256+ return nil, info, translate(e)
257+ }
258+ if _, e = f.Seek(offset, io.SeekStart); e != nil {
259+ _ = f.Close()
260+ return nil, info, e
261+ }
262+ var r io.Reader = ctxReader{ctx, f}
263+ if length >= 0 {
264+ r = io.LimitReader(r, length)
265+ }
266+ return limitedCloser{r, f}, info, nil
267+}
268+
269+// Delete removes an object, treating absence as success.
270+func (s *Store) Delete(ctx context.Context, key string) error {
271+ logEntry := zerowrap.FromCtx(ctx)
272+ logEntry.Debug().Msg("blobdisk: Delete")
273+ if e := valid(key); e != nil {
274+ return e
275+ }
276+ if e := ctx.Err(); e != nil {
277+ return e
278+ }
279+ e := os.Remove(s.object(key))
280+ if errors.Is(e, fs.ErrNotExist) {
281+ return nil
282+ }
283+ return e
284+}
285+
286+// List visits matching objects in lexical order.
287+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
288+ logEntry := zerowrap.FromCtx(ctx)
289+ logEntry.Debug().Msg("blobdisk: List")
290+ root := filepath.Join(s.root, "objects")
291+ var infos []ports.BlobInfo
292+ e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error {
293+ if e != nil {
294+ return e
295+ }
296+ if e = ctx.Err(); e != nil {
297+ return e
298+ }
299+ if !d.Type().IsRegular() {
300+ return nil
301+ }
302+ key, e := filepath.Rel(root, path)
303+ if e != nil {
304+ return e
305+ }
306+ key = filepath.ToSlash(key)
307+ if !strings.HasPrefix(key, prefix) {
308+ return nil
309+ }
310+ i, e := d.Info()
311+ if e != nil {
312+ return e
313+ }
314+ infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()})
315+ return nil
316+ })
317+ if errors.Is(e, fs.ErrNotExist) {
318+ return nil
319+ }
320+ if e != nil {
321+ return e
322+ }
323+ sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key })
324+ for _, i := range infos {
325+ if e = ctx.Err(); e != nil {
326+ return e
327+ }
328+ if e = fn(i); e != nil {
329+ return e
330+ }
331+ }
332+ return nil
333+}
334+
335+type upload struct {
336+ s *Store
337+ key, id string
338+ done bool
339+}
340+type metadata struct {
341+ Key string
342+ Started time.Time
343+}
344+
345+func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) }
346+
347+// NewMultipart starts an isolated staging upload.
348+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
349+ logEntry := zerowrap.FromCtx(ctx)
350+ logEntry.Debug().Msg("blobdisk: NewMultipart")
351+ if e := valid(key); e != nil {
352+ return nil, e
353+ }
354+ if e := s.init(ctx); e != nil {
355+ return nil, e
356+ }
357+ b := make([]byte, 16)
358+ if _, e := rand.Read(b); e != nil {
359+ return nil, e
360+ }
361+ u := &upload{s: s, key: key, id: hex.EncodeToString(b)}
362+ stage := filepath.Join(s.root, "tmp", "mp-"+u.id)
363+ if e := os.Mkdir(stage, 0700); e != nil {
364+ return nil, e
365+ }
366+ if e := writeMetadata(stage, metadata{key, time.Now().UTC()}); e != nil {
367+ _ = os.RemoveAll(stage)
368+ return nil, e
369+ }
370+ if e := os.Rename(stage, u.dir()); e != nil {
371+ _ = os.RemoveAll(stage)
372+ return nil, e
373+ }
374+ if e := syncDir(filepath.Join(s.root, "multipart")); e != nil {
375+ _ = os.RemoveAll(u.dir())
376+ return nil, e
377+ }
378+ return u, nil
379+}
380+
381+// writeMetadata durably writes dir/metadata via temp file and rename.
382+func writeMetadata(dir string, m metadata) error {
383+ data, e := json.Marshal(m)
384+ if e != nil {
385+ return e
386+ }
387+ f, e := os.CreateTemp(dir, ".meta-*")
388+ if e != nil {
389+ return e
390+ }
391+ _, e = f.Write(data)
392+ if e == nil {
393+ e = f.Sync()
394+ }
395+ if e = errors.Join(e, f.Close()); e == nil {
396+ e = os.Rename(f.Name(), filepath.Join(dir, "metadata"))
397+ }
398+ if e != nil {
399+ _ = os.Remove(f.Name())
400+ return e
401+ }
402+ return syncDir(dir)
403+}
404+func (u *upload) ID() string { return u.id }
405+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
406+ logEntry := zerowrap.FromCtx(ctx)
407+ logEntry.Debug().Msg("blobdisk: UploadPart")
408+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 {
409+ return domain.ErrInvalid
410+ }
411+ f, e := os.CreateTemp(u.dir(), ".tmp-*")
412+ if e != nil {
413+ return e
414+ }
415+ defer func() { _ = os.Remove(f.Name()) }()
416+ count, e := io.Copy(f, ctxReader{ctx, r})
417+ if e == nil && count != size {
418+ e = domain.ErrInvalid
419+ }
420+ if e == nil {
421+ e = f.Sync()
422+ }
423+ e = errors.Join(e, f.Close())
424+ if e != nil {
425+ return e
426+ }
427+ return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n)))
428+}
429+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
430+ logEntry := zerowrap.FromCtx(ctx)
431+ logEntry.Debug().Msg("blobdisk: Complete")
432+ if u.done {
433+ return ports.BlobInfo{}, domain.ErrInvalid
434+ }
435+ u.done = true
436+ defer func() { _ = os.RemoveAll(u.dir()) }()
437+ entries, e := os.ReadDir(u.dir())
438+ if e != nil {
439+ return ports.BlobInfo{}, e
440+ }
441+ var names []string
442+ for _, d := range entries {
443+ if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 {
444+ names = append(names, d.Name())
445+ }
446+ }
447+ sort.Strings(names)
448+ if len(names) == 0 {
449+ return ports.BlobInfo{}, domain.ErrInvalid
450+ }
451+ var size int64
452+ var readers []io.Reader
453+ var files []*os.File
454+ defer func() {
455+ for _, f := range files {
456+ _ = f.Close()
457+ }
458+ }()
459+ for i, n := range names {
460+ f, e := os.Open(filepath.Join(u.dir(), n))
461+ if e != nil {
462+ return ports.BlobInfo{}, e
463+ }
464+ files = append(files, f)
465+ info, e := f.Stat()
466+ if e != nil {
467+ return ports.BlobInfo{}, e
468+ }
469+ if i < len(names)-1 && info.Size() < ports.BlobMinPartSize {
470+ return ports.BlobInfo{}, domain.ErrInvalid
471+ }
472+ size += info.Size()
473+ readers = append(readers, f)
474+ }
475+ info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size)
476+ info.Digest = ""
477+ return info, e
478+}
479+func (u *upload) Abort(ctx context.Context) error {
480+ logEntry := zerowrap.FromCtx(ctx)
481+ logEntry.Debug().Msg("blobdisk: Abort")
482+ if u.done {
483+ return domain.ErrInvalid
484+ }
485+ u.done = true
486+ return u.s.AbortMultipart(ctx, u.key, u.id)
487+}
488+func validID(id string) bool {
489+ b, e := hex.DecodeString(id)
490+ return e == nil && len(b) == 16 && id == strings.ToLower(id)
491+}
492+
493+// AbortMultipart removes a listed upload by identity.
494+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
495+ logEntry := zerowrap.FromCtx(ctx)
496+ logEntry.Debug().Msg("blobdisk: AbortMultipart")
497+ if e := valid(key); e != nil {
498+ return e
499+ }
500+ if !validID(id) {
501+ return domain.ErrInvalid
502+ }
503+ if e := ctx.Err(); e != nil {
504+ return e
505+ }
506+ dir := filepath.Join(s.root, "multipart", id)
507+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
508+ if errors.Is(e, fs.ErrNotExist) {
509+ return nil
510+ }
511+ if e != nil {
512+ return e
513+ }
514+ var m metadata
515+ if e = json.Unmarshal(b, &m); e != nil {
516+ return e
517+ }
518+ if m.Key != key {
519+ return domain.ErrInvalid
520+ }
521+ return os.RemoveAll(dir)
522+}
523+
524+// ListMultipart visits unfinished uploads matching prefix.
525+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
526+ logEntry := zerowrap.FromCtx(ctx)
527+ logEntry.Debug().Msg("blobdisk: ListMultipart")
528+ entries, e := os.ReadDir(filepath.Join(s.root, "multipart"))
529+ if errors.Is(e, fs.ErrNotExist) {
530+ return nil
531+ }
532+ if e != nil {
533+ return e
534+ }
535+ if e = s.reclaimTmp(ctx, tmpGrace); e != nil {
536+ logEntry.Warn().Err(e).Msg("blobdisk: tmp reclamation failed")
537+ }
538+ for _, d := range entries {
539+ if e = ctx.Err(); e != nil {
540+ return e
541+ }
542+ if !d.IsDir() || !validID(d.Name()) {
543+ continue
544+ }
545+ dir := filepath.Join(s.root, "multipart", d.Name())
546+ var m metadata
547+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
548+ if e == nil {
549+ e = json.Unmarshal(b, &m)
550+ }
551+ if e == nil && !ports.ValidBlobKey(m.Key) {
552+ e = domain.ErrInvalid
553+ }
554+ if e != nil {
555+ logEntry.Warn().Err(e).Str("id", d.Name()).Msg("blobdisk: skipping malformed multipart upload")
556+ if fi, ie := d.Info(); ie == nil && fi.ModTime().Before(now().Add(-tmpGrace)) {
557+ if re := os.RemoveAll(dir); re != nil {
558+ logEntry.Warn().Err(re).Str("id", d.Name()).Msg("blobdisk: remove malformed multipart failed")
559+ } else {
560+ logEntry.Info().Str("id", d.Name()).Msg("blobdisk: removed malformed multipart upload")
561+ }
562+ }
563+ continue
564+ }
565+ if strings.HasPrefix(m.Key, prefix) {
566+ if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil {
567+ return e
568+ }
569+ }
570+ }
571+ return nil
572+}
A internal/adapters/blobdisk/blobdisk_internal_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func age(t *testing.T, p string) {
17+ t.Helper()
18+ old := time.Now().Add(-2 * tmpGrace)
19+ require.NoError(t, os.Chtimes(p, old, old))
20+}
21+
22+func TestCheckReclaimsOldTmp(t *testing.T) {
23+ root := t.TempDir()
24+ s := New(root)
25+ ctx := context.Background()
26+ require.NoError(t, s.Check(ctx))
27+ oldF, freshF := filepath.Join(root, "tmp", ".tmp-old"), filepath.Join(root, "tmp", ".tmp-fresh")
28+ oldD := filepath.Join(root, "tmp", "mp-old")
29+ require.NoError(t, os.WriteFile(oldF, []byte("x"), 0600))
30+ require.NoError(t, os.WriteFile(freshF, []byte("x"), 0600))
31+ require.NoError(t, os.MkdirAll(oldD, 0700))
32+ require.NoError(t, os.WriteFile(filepath.Join(oldD, "metadata"), []byte("x"), 0600))
33+ age(t, oldF)
34+ age(t, oldD)
35+ require.NoError(t, s.Check(ctx))
36+ require.NoFileExists(t, oldF)
37+ require.NoDirExists(t, oldD)
38+ require.FileExists(t, freshF)
39+}
40+
41+func TestListMultipartSkipsAndReclaimsMalformed(t *testing.T) {
42+ root := t.TempDir()
43+ s := New(root)
44+ ctx := context.Background()
45+ a, err := s.NewMultipart(ctx, "a")
46+ require.NoError(t, err)
47+ c, err := s.NewMultipart(ctx, "c")
48+ require.NoError(t, err)
49+ mk := func(id string, meta *string) string {
50+ d := filepath.Join(root, "multipart", id)
51+ require.NoError(t, os.Mkdir(d, 0700))
52+ if meta != nil {
53+ require.NoError(t, os.WriteFile(filepath.Join(d, "metadata"), []byte(*meta), 0600))
54+ }
55+ return d
56+ }
57+ trunc := `{"Key":`
58+ freshBad := mk(strings.Repeat("1", 32), &trunc)
59+ oldBad := mk(strings.Repeat("2", 32), nil)
60+ oldTrunc := mk(strings.Repeat("3", 32), &trunc)
61+ age(t, oldBad)
62+ age(t, oldTrunc)
63+ var got []string
64+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { got = append(got, i.ID); return nil }))
65+ require.ElementsMatch(t, []string{a.ID(), c.ID()}, got)
66+ require.DirExists(t, freshBad)
67+ require.NoDirExists(t, oldBad)
68+ require.NoDirExists(t, oldTrunc)
69+ entries, err := os.ReadDir(filepath.Join(root, "tmp"))
70+ require.NoError(t, err)
71+ require.Empty(t, entries)
72+}
73+
74+func TestMkdirAllSyncSyncsParents(t *testing.T) {
75+ root := t.TempDir()
76+ s := New(root)
77+ var synced []string
78+ orig := syncDir
79+ syncDir = func(p string) error { synced = append(synced, p); return orig(p) }
80+ t.Cleanup(func() { syncDir = orig })
81+ _, err := s.Put(context.Background(), "a/b/c/obj", strings.NewReader("hi"), 2)
82+ require.NoError(t, err)
83+ obj := filepath.Join(root, "objects")
84+ for _, want := range []string{obj, filepath.Join(obj, "a"), filepath.Join(obj, "a/b"), filepath.Join(obj, "a/b/c")} {
85+ require.Contains(t, synced, want)
86+ }
87+}
A internal/adapters/blobdisk/blobdisk_test.go +29 -0
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk_test
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/adapters/blobdisk"
11+ "bnema.dev/ermite/internal/adapters/blobtest"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestContract(t *testing.T) {
18+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) })
19+}
20+func TestCrashTempInvisible(t *testing.T) {
21+ root := t.TempDir()
22+ s := blobdisk.New(root)
23+ ctx := context.Background()
24+ require.NoError(t, s.Check(ctx))
25+ require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600))
26+ require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil }))
27+ _, e := s.Stat(ctx, ".tmp-crashed")
28+ require.ErrorIs(t, e, domain.ErrNotFound)
29+}
A internal/adapters/blobs3/blobs3.go +461 -0
@@ -0,0 +1,461 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobs3 implements the blob port with the object, multipart and
4+// listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and
5+// no bucket creation, policy, ACL, tagging or lifecycle API is ever called.
6+package blobs3
7+
8+import (
9+ "bytes"
10+ "context"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "net/http"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "github.com/aws/aws-sdk-go-v2/aws"
26+ "github.com/aws/aws-sdk-go-v2/credentials"
27+ "github.com/aws/aws-sdk-go-v2/service/s3"
28+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
29+ "github.com/bnema/zerowrap"
30+)
31+
32+// Store is a provider-independent S3 blob store.
33+type Store struct {
34+ client *s3.Client
35+ cfg Config
36+}
37+
38+var _ ports.BlobStore = (*Store)(nil)
39+
40+// New builds a client without consulting ambient configuration or probing.
41+func New(ctx context.Context, cfg Config) (*Store, error) {
42+ if e := ctx.Err(); e != nil {
43+ return nil, e
44+ }
45+ if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
46+ return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid)
47+ }
48+ cfg.Prefix = strings.Trim(cfg.Prefix, "/")
49+ if cfg.Prefix != "" {
50+ cfg.Prefix += "/"
51+ }
52+ client := cfg.HTTPClient
53+ if client == nil {
54+ transport := http.DefaultTransport.(*http.Transport).Clone()
55+ transport.IdleConnTimeout = 60 * time.Second
56+ transport.ResponseHeaderTimeout = 60 * time.Second
57+ client = &http.Client{Transport: transport}
58+ }
59+ ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3}
60+ c := s3.NewFromConfig(ac, func(o *s3.Options) {
61+ if cfg.Endpoint != "" {
62+ o.BaseEndpoint = aws.String(cfg.Endpoint)
63+ }
64+ o.UsePathStyle = cfg.ForcePathStyle
65+ })
66+ return &Store{client: c, cfg: cfg}, nil
67+}
68+func valid(key string) error {
69+ if !ports.ValidBlobKey(key) {
70+ return domain.ErrInvalid
71+ }
72+ return nil
73+}
74+func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) }
75+
76+type statusError interface{ HTTPStatusCode() int }
77+type codeError interface{ ErrorCode() string }
78+
79+func status(err error) int {
80+ var e statusError
81+ if errors.As(err, &e) {
82+ return e.HTTPStatusCode()
83+ }
84+ return 0
85+}
86+func translate(err error) error {
87+ if err == nil {
88+ return nil
89+ }
90+ var c codeError
91+ if errors.As(err, &c) {
92+ switch c.ErrorCode() {
93+ case "InvalidRange":
94+ return domain.ErrOutOfRange
95+ case "NoSuchKey", "NotFound":
96+ return domain.ErrNotFound
97+ case "EntityTooSmall":
98+ return domain.ErrInvalid
99+ }
100+ }
101+ switch status(err) {
102+ case 404:
103+ return domain.ErrNotFound
104+ case 416:
105+ return domain.ErrOutOfRange
106+ }
107+ return err
108+}
109+
110+type ctxReader struct {
111+ ctx context.Context
112+ r io.Reader
113+}
114+
115+func (r ctxReader) Read(p []byte) (int, error) {
116+ if e := r.ctx.Err(); e != nil {
117+ return 0, e
118+ }
119+ return r.r.Read(p)
120+}
121+
122+// One pooled buffer per in-flight upload bounds staging to BlobPartSize.
123+var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }}
124+
125+func exactEnd(ctx context.Context, r io.Reader) error {
126+ var b [1]byte
127+ n, e := io.ReadFull(ctxReader{ctx, r}, b[:])
128+ if n != 0 {
129+ return domain.ErrInvalid
130+ }
131+ if e == io.EOF {
132+ return nil
133+ }
134+ return e
135+}
136+func readPart(ctx context.Context, r io.Reader, b []byte) error {
137+ _, e := io.ReadFull(ctxReader{ctx, r}, b)
138+ if e == io.EOF || e == io.ErrUnexpectedEOF {
139+ return domain.ErrInvalid
140+ }
141+ return e
142+}
143+
144+// Put uses signed, seekable bodies, multiparting objects larger than one buffer.
145+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
146+ logEntry := zerowrap.FromCtx(ctx)
147+ logEntry.Debug().Msg("blobs3: Put")
148+ if e := valid(key); e != nil {
149+ return ports.BlobInfo{}, e
150+ }
151+ if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts {
152+ return ports.BlobInfo{}, domain.ErrInvalid
153+ }
154+ bp := buffers.Get().(*[]byte)
155+ defer buffers.Put(bp)
156+ h := sha256.New()
157+ source := io.TeeReader(r, h)
158+ if size <= ports.BlobPartSize {
159+ b := (*bp)[:int(size)]
160+ if e := readPart(ctx, source, b); e != nil {
161+ return ports.BlobInfo{}, e
162+ }
163+ if e := exactEnd(ctx, r); e != nil {
164+ return ports.BlobInfo{}, e
165+ }
166+ _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)})
167+ if e != nil {
168+ return ports.BlobInfo{}, translate(e)
169+ }
170+ } else {
171+ u, e := s.NewMultipart(ctx, key)
172+ if e != nil {
173+ return ports.BlobInfo{}, e
174+ }
175+ completed := false
176+ defer func() {
177+ if !completed {
178+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
179+ defer cancel()
180+ _ = s.AbortMultipart(cleanup, key, u.ID())
181+ }
182+ }()
183+ remaining := size
184+ for n := 1; remaining > 0; n++ {
185+ count := min(remaining, int64(ports.BlobPartSize))
186+ b := (*bp)[:int(count)]
187+ if e = readPart(ctx, source, b); e != nil {
188+ return ports.BlobInfo{}, e
189+ }
190+ if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil {
191+ return ports.BlobInfo{}, e
192+ }
193+ remaining -= count
194+ }
195+ if e = exactEnd(ctx, r); e != nil {
196+ return ports.BlobInfo{}, e
197+ }
198+ if _, e = u.Complete(ctx); e != nil {
199+ return ports.BlobInfo{}, e
200+ }
201+ completed = true
202+ }
203+ log := zerowrap.FromCtx(ctx)
204+ log.Debug().Str("key", key).Int64("size", size).Msg("blob stored")
205+ return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil
206+}
207+
208+// Stat retrieves metadata without downloading content.
209+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
210+ if e := valid(key); e != nil {
211+ return ports.BlobInfo{}, e
212+ }
213+ o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
214+ if e != nil {
215+ return ports.BlobInfo{}, translate(e)
216+ }
217+ return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil
218+}
219+
220+// Open streams a single range of an object.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port
223+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
224+ if e := valid(key); e != nil {
225+ return nil, ports.BlobInfo{}, e
226+ }
227+ if offset < 0 {
228+ return nil, ports.BlobInfo{}, domain.ErrInvalid
229+ }
230+ if length == 0 {
231+ info, e := s.Stat(ctx, key)
232+ if e != nil {
233+ return nil, info, e
234+ }
235+ if offset >= info.Size {
236+ return nil, info, domain.ErrOutOfRange
237+ }
238+ return io.NopCloser(strings.NewReader("")), info, nil
239+ }
240+ rg := fmt.Sprintf("bytes=%d-", offset)
241+ if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset {
242+ rg += strconv.FormatInt(offset+length-1, 10)
243+ }
244+ o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)})
245+ if e != nil {
246+ return nil, ports.BlobInfo{}, translate(e)
247+ }
248+ size := aws.ToInt64(o.ContentLength)
249+ if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok {
250+ if n, e := strconv.ParseInt(total, 10, 64); e == nil {
251+ size = n
252+ }
253+ }
254+ return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil
255+}
256+
257+// Delete removes a key; absence is success.
258+func (s *Store) Delete(ctx context.Context, key string) error {
259+ logEntry := zerowrap.FromCtx(ctx)
260+ logEntry.Debug().Msg("blobs3: Delete")
261+ if e := valid(key); e != nil {
262+ return e
263+ }
264+ _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
265+ if status(e) == 404 {
266+ return nil
267+ }
268+ return translate(e)
269+}
270+
271+// List visits matching objects lexicographically.
272+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
273+ logEntry := zerowrap.FromCtx(ctx)
274+ logEntry.Debug().Msg("blobs3: List")
275+ p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)})
276+ for p.HasMorePages() {
277+ o, e := p.NextPage(ctx)
278+ if e != nil {
279+ return translate(e)
280+ }
281+ for _, v := range o.Contents {
282+ if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil {
283+ return e
284+ }
285+ }
286+ }
287+ return nil
288+}
289+
290+// Check probes the existing bucket without modifying it.
291+func (s *Store) Check(ctx context.Context) error {
292+ logEntry := zerowrap.FromCtx(ctx)
293+ logEntry.Debug().Msg("blobs3: Check")
294+ _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)})
295+ if status(e) == 403 {
296+ _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)})
297+ }
298+ if e == nil {
299+ return nil
300+ }
301+ where := s.cfg.Endpoint
302+ if where == "" {
303+ where = s.cfg.Region
304+ }
305+ return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e)
306+}
307+
308+type part struct {
309+ etag string
310+ size int64
311+}
312+type upload struct {
313+ s *Store
314+ key, id string
315+ parts map[int]part
316+ done bool
317+}
318+
319+// NewMultipart initiates an object upload.
320+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
321+ logEntry := zerowrap.FromCtx(ctx)
322+ logEntry.Debug().Msg("blobs3: NewMultipart")
323+ if e := valid(key); e != nil {
324+ return nil, e
325+ }
326+ o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
327+ if e != nil {
328+ return nil, translate(e)
329+ }
330+ return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil
331+}
332+func (u *upload) ID() string { return u.id }
333+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
334+ logEntry := zerowrap.FromCtx(ctx)
335+ logEntry.Debug().Msg("blobs3: UploadPart")
336+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize {
337+ return domain.ErrInvalid
338+ }
339+ if e := ctx.Err(); e != nil {
340+ return e
341+ }
342+ var body io.ReadSeeker
343+ if seeker, ok := r.(io.ReadSeeker); ok {
344+ start, e := seeker.Seek(0, io.SeekCurrent)
345+ if e != nil {
346+ return e
347+ }
348+ end, e := seeker.Seek(0, io.SeekEnd)
349+ if e != nil {
350+ return e
351+ }
352+ if _, e = seeker.Seek(start, io.SeekStart); e != nil {
353+ return e
354+ }
355+ if end-start != size {
356+ return domain.ErrInvalid
357+ }
358+ body = seeker
359+ } else {
360+ bp := buffers.Get().(*[]byte)
361+ defer buffers.Put(bp)
362+ b := (*bp)[:int(size)]
363+ if e := readPart(ctx, r, b); e != nil {
364+ return e
365+ }
366+ if e := exactEnd(ctx, r); e != nil {
367+ return e
368+ }
369+ body = bytes.NewReader(b)
370+ }
371+ o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)})
372+ if e != nil {
373+ return translate(e)
374+ }
375+ u.parts[n] = part{aws.ToString(o.ETag), size}
376+ log := zerowrap.FromCtx(ctx)
377+ log.Debug().Int("part", n).Msg("blob part uploaded")
378+ return nil
379+}
380+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
381+ logEntry := zerowrap.FromCtx(ctx)
382+ logEntry.Debug().Msg("blobs3: Complete")
383+ if u.done {
384+ return ports.BlobInfo{}, domain.ErrInvalid
385+ }
386+ u.done = true
387+ var nums []int
388+ for n := range u.parts {
389+ nums = append(nums, n)
390+ }
391+ sort.Ints(nums)
392+ var parts []types.CompletedPart
393+ var size int64
394+ invalid := len(nums) == 0
395+ for i, n := range nums {
396+ p := u.parts[n]
397+ if i < len(nums)-1 && p.size < ports.BlobMinPartSize {
398+ invalid = true
399+ }
400+ size += p.size
401+ parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)})
402+ }
403+ if invalid {
404+ _ = u.s.AbortMultipart(ctx, u.key, u.id)
405+ return ports.BlobInfo{}, domain.ErrInvalid
406+ }
407+ _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}})
408+ if e != nil {
409+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
410+ defer cancel()
411+ _ = u.s.AbortMultipart(cleanup, u.key, u.id)
412+ return ports.BlobInfo{}, translate(e)
413+ }
414+ return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil
415+}
416+func (u *upload) Abort(ctx context.Context) error {
417+ logEntry := zerowrap.FromCtx(ctx)
418+ logEntry.Debug().Msg("blobs3: Abort")
419+ if u.done {
420+ return domain.ErrInvalid
421+ }
422+ u.done = true
423+ return u.s.AbortMultipart(ctx, u.key, u.id)
424+}
425+
426+// AbortMultipart discards a listed upload.
427+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
428+ logEntry := zerowrap.FromCtx(ctx)
429+ logEntry.Debug().Msg("blobs3: AbortMultipart")
430+ if e := valid(key); e != nil {
431+ return e
432+ }
433+ _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)})
434+ if status(e) == 404 {
435+ return nil
436+ }
437+ return translate(e)
438+}
439+
440+// ListMultipart visits unfinished uploads matching prefix.
441+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
442+ logEntry := zerowrap.FromCtx(ctx)
443+ logEntry.Debug().Msg("blobs3: ListMultipart")
444+ input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}
445+ for {
446+ o, e := s.client.ListMultipartUploads(ctx, input)
447+ if e != nil {
448+ return translate(e)
449+ }
450+ for _, v := range o.Uploads {
451+ if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil {
452+ return e
453+ }
454+ }
455+ if !aws.ToBool(o.IsTruncated) {
456+ return nil
457+ }
458+ input.KeyMarker = o.NextKeyMarker
459+ input.UploadIdMarker = o.NextUploadIdMarker
460+ }
461+}
A internal/adapters/blobs3/blobs3_test.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/johannesboyne/gofakes3"
15+ "github.com/johannesboyne/gofakes3/backend/s3mem"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) {
20+ t.Helper()
21+ backend := s3mem.New()
22+ require.NoError(t, backend.CreateBucket("b"))
23+ handler := gofakes3.New(backend).Server()
24+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+ t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI())
26+ for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} {
27+ if r.URL.Query().Has(q) {
28+ t.Errorf("unexpected admin operation %s", r.URL)
29+ }
30+ }
31+ if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" {
32+ t.Error("unexpected bucket creation")
33+ }
34+ if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") {
35+ if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" {
36+ t.Error("payload must be signed")
37+ }
38+ }
39+ handler.ServeHTTP(w, r)
40+ }))
41+ t.Cleanup(srv.Close)
42+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
43+ require.NoError(t, e)
44+ return s, backend
45+}
46+func TestContract(t *testing.T) {
47+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s })
48+}
49+func TestPrefix(t *testing.T) {
50+ s, b := fake(t)
51+ _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1)
52+ require.NoError(t, e)
53+ objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{})
54+ require.NoError(t, e)
55+ require.Len(t, objects.Contents, 1)
56+ require.Equal(t, "pfx/ci/one", objects.Contents[0].Key)
57+}
58+func TestMissingBucket(t *testing.T) {
59+ s, b := fake(t)
60+ require.NoError(t, b.DeleteBucket("b"))
61+ e := s.Check(context.Background())
62+ require.ErrorContains(t, e, `bucket "b" is not reachable at http://`)
63+ require.NotContains(t, e.Error(), "test-secret")
64+}
65+
66+func TestCheckDeniedHeadFallsBack(t *testing.T) {
67+ var listed bool
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ if r.Method == http.MethodHead {
70+ w.WriteHeader(http.StatusForbidden)
71+ return
72+ }
73+ if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" {
74+ t.Errorf("unexpected probe: %s %s", r.Method, r.URL)
75+ }
76+ listed = true
77+ w.Header().Set("Content-Type", "application/xml")
78+ _, _ = w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`))
79+ }))
80+ defer srv.Close()
81+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
82+ require.NoError(t, e)
83+ require.NoError(t, s.Check(context.Background()))
84+ require.True(t, listed)
85+}
A internal/adapters/blobs3/config.go +13 -0
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3
3+
4+import "net/http"
5+
6+// Config supplies explicit credentials and endpoint settings; no ambient
7+// credential source is consulted.
8+type Config struct {
9+ Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string
10+ ForcePathStyle bool
11+ // HTTPClient optionally overrides the transport (primarily for tests).
12+ HTTPClient *http.Client
13+}
A internal/adapters/blobs3/integration_test.go +44 -0
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "os"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestIntegration_Contract(t *testing.T) {
18+ get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) }
19+ if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" {
20+ t.Skip("set ERMITE_TEST_S3_* to run")
21+ }
22+ region := get("REGION")
23+ if region == "" {
24+ region = "us-east-1"
25+ }
26+ var opts []blobtest.Option
27+ // MinIO lists unfinished uploads by whole key only.
28+ if get("EXACT_KEY_UPLOAD_LIST") == "1" {
29+ opts = append(opts, blobtest.ExactKeyUploadList())
30+ }
31+ blobtest.Run(t, func(t *testing.T) ports.BlobStore {
32+ b := make([]byte, 16)
33+ _, e := rand.Read(b)
34+ require.NoError(t, e)
35+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"})
36+ require.NoError(t, e)
37+ t.Cleanup(func() {
38+ ctx := context.Background()
39+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) }))
40+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) }))
41+ })
42+ return s
43+ }, opts...)
44+}
A internal/adapters/blobtest/suite.go +256 -0
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobtest provides the shared BlobStore contract suite.
4+package blobtest
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "errors"
12+ "io"
13+ "strings"
14+ "testing"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+// Option adapts Run to a provider quirk.
22+type Option func(*options)
23+
24+type options struct{ exactKeyUploadList bool }
25+
26+// ExactKeyUploadList is for providers (MinIO) that list unfinished multipart
27+// uploads only when the prefix is a whole object key: the suite then lists by
28+// key instead of by directory prefix.
29+func ExactKeyUploadList() Option { return func(o *options) { o.exactKeyUploadList = true } }
30+
31+// Run checks the blob contract against a fresh store for each subtest.
32+func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore, opts ...Option) {
33+ ctx := context.Background()
34+ var o options
35+ for _, opt := range opts {
36+ opt(&o)
37+ }
38+ // uploadPrefix is the ListMultipart prefix for key: dir, or key itself
39+ // on a provider that lists by whole key only.
40+ uploadPrefix := func(dir, key string) string {
41+ if o.exactKeyUploadList {
42+ return key
43+ }
44+ return dir
45+ }
46+ t.Run("roundtrip", func(t *testing.T) {
47+ s := newStore(t)
48+ require.NoError(t, s.Check(ctx))
49+ data := []byte("abcdefghij")
50+ info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data)))
51+ require.NoError(t, e)
52+ h := sha256.Sum256(data)
53+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
54+ info, e = s.Stat(ctx, "ci/roundtrip")
55+ require.NoError(t, e)
56+ require.EqualValues(t, len(data), info.Size)
57+ require.False(t, info.ModTime.IsZero())
58+ for _, r := range []struct {
59+ off, len int64
60+ want string
61+ }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} {
62+ rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len)
63+ require.NoError(t, e)
64+ b, e := io.ReadAll(rc)
65+ require.NoError(t, e)
66+ require.NoError(t, rc.Close())
67+ require.Equal(t, r.want, string(b))
68+ require.EqualValues(t, 10, meta.Size)
69+ }
70+ for _, off := range []int64{10, 11} {
71+ _, _, e := s.Open(ctx, "ci/roundtrip", off, -1)
72+ require.ErrorIs(t, e, domain.ErrOutOfRange)
73+ }
74+ _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3)
75+ require.NoError(t, e)
76+ rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1)
77+ require.NoError(t, e)
78+ b, e := io.ReadAll(rc)
79+ require.NoError(t, e)
80+ _ = rc.Close()
81+ require.Equal(t, "new", string(b))
82+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
83+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
84+ _, e = s.Stat(ctx, "ci/roundtrip")
85+ require.ErrorIs(t, e, domain.ErrNotFound)
86+ _, _, e = s.Open(ctx, "ci/absent", 0, -1)
87+ require.ErrorIs(t, e, domain.ErrNotFound)
88+ })
89+ t.Run("list_and_reserved_names", func(t *testing.T) {
90+ s := newStore(t)
91+ keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"}
92+ for _, k := range keys {
93+ _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k)))
94+ require.NoError(t, e)
95+ r, _, e := s.Open(ctx, k, 0, -1)
96+ require.NoError(t, e)
97+ b, e := io.ReadAll(r)
98+ _ = r.Close()
99+ require.NoError(t, e)
100+ require.Equal(t, k, string(b))
101+ }
102+ var got []string
103+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
104+ require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got)
105+ got = nil
106+ require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
107+ require.Equal(t, []string{"ci/a", "ci/z"}, got)
108+ stop := errors.New("stop")
109+ calls := 0
110+ require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop)
111+ require.Equal(t, 1, calls)
112+ })
113+ t.Run("invalid", func(t *testing.T) {
114+ s := newStore(t)
115+ for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} {
116+ _, e := s.Put(ctx, key, strings.NewReader("x"), 1)
117+ require.ErrorIs(t, e, domain.ErrInvalid)
118+ _, e = s.Stat(ctx, key)
119+ require.ErrorIs(t, e, domain.ErrInvalid)
120+ _, _, e = s.Open(ctx, key, 0, -1)
121+ require.ErrorIs(t, e, domain.ErrInvalid)
122+ require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid)
123+ _, e = s.NewMultipart(ctx, key)
124+ require.ErrorIs(t, e, domain.ErrInvalid)
125+ require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid)
126+ }
127+ _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1)
128+ require.ErrorIs(t, e, domain.ErrInvalid)
129+ })
130+ t.Run("multipart", func(t *testing.T) {
131+ s := newStore(t)
132+ u, e := s.NewMultipart(ctx, "ci/multi")
133+ require.NoError(t, e)
134+ var all []byte
135+ for n := 1; n <= 4; n++ {
136+ size := ports.BlobMinPartSize
137+ if n == 4 {
138+ size = 1024
139+ }
140+ data := bytes.Repeat([]byte{byte(n)}, size)
141+ all = append(all, data...)
142+ require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size)))
143+ }
144+ info, e := u.Complete(ctx)
145+ require.NoError(t, e)
146+ require.Empty(t, info.Digest)
147+ r, _, e := s.Open(ctx, "ci/multi", 0, -1)
148+ require.NoError(t, e)
149+ b, e := io.ReadAll(r)
150+ _ = r.Close()
151+ require.NoError(t, e)
152+ require.Equal(t, all, b)
153+ })
154+ t.Run("abort", func(t *testing.T) {
155+ s := newStore(t)
156+ for _, byID := range []bool{false, true} {
157+ u, e := s.NewMultipart(ctx, "ci/abort")
158+ require.NoError(t, e)
159+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
160+ var uploads []ports.MultipartInfo
161+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("ci/", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
162+ require.Len(t, uploads, 1)
163+ require.Equal(t, u.ID(), uploads[0].ID)
164+ require.Equal(t, "ci/abort", uploads[0].Key)
165+ require.False(t, uploads[0].Started.IsZero())
166+ if byID {
167+ require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID()))
168+ } else {
169+ require.NoError(t, u.Abort(ctx))
170+ }
171+ _, e = s.Stat(ctx, "ci/abort")
172+ require.ErrorIs(t, e, domain.ErrNotFound)
173+ uploads = nil
174+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
175+ require.Empty(t, uploads)
176+ }
177+ })
178+ t.Run("undersized_part", func(t *testing.T) {
179+ s := newStore(t)
180+ u, e := s.NewMultipart(ctx, "ci/small")
181+ require.NoError(t, e)
182+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
183+ require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1))
184+ _, e = u.Complete(ctx)
185+ require.ErrorIs(t, e, domain.ErrInvalid)
186+ _, e = s.Stat(ctx, "ci/small")
187+ require.ErrorIs(t, e, domain.ErrNotFound)
188+ })
189+ t.Run("failed_put", func(t *testing.T) {
190+ s := newStore(t)
191+ _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10)
192+ require.Error(t, e)
193+ _, e = s.Stat(ctx, "ci/fail")
194+ require.ErrorIs(t, e, domain.ErrNotFound)
195+ for _, v := range []struct {
196+ data string
197+ size int64
198+ }{{"a", 2}, {"abc", 2}} {
199+ _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size)
200+ require.ErrorIs(t, e, domain.ErrInvalid)
201+ _, e = s.Stat(ctx, "ci/size")
202+ require.ErrorIs(t, e, domain.ErrNotFound)
203+ }
204+ })
205+ t.Run("large_put", func(t *testing.T) {
206+ s := newStore(t)
207+ data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100)
208+ info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data)))
209+ require.NoError(t, e)
210+ h := sha256.Sum256(data)
211+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
212+ r, _, e := s.Open(ctx, "ci/large", 0, -1)
213+ require.NoError(t, e)
214+ got, e := io.ReadAll(r)
215+ require.NoError(t, e)
216+ require.NoError(t, r.Close())
217+ require.Equal(t, data, got)
218+ for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} {
219+ _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size)
220+ require.ErrorIs(t, e, domain.ErrInvalid)
221+ _, e = s.Stat(ctx, "ci/badlarge")
222+ require.ErrorIs(t, e, domain.ErrNotFound)
223+ }
224+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/badlarge"), func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil }))
225+ })
226+
227+ t.Run("cancel_put", func(t *testing.T) {
228+ s := newStore(t)
229+ c, cancel := context.WithCancel(ctx)
230+ defer cancel()
231+ r := &cancelReader{cancel: cancel}
232+ _, e := s.Put(c, "ci/cancel", r, 1024)
233+ require.ErrorIs(t, e, context.Canceled)
234+ _, e = s.Stat(ctx, "ci/cancel")
235+ require.ErrorIs(t, e, domain.ErrNotFound)
236+ })
237+}
238+
239+type failReader struct{}
240+
241+func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") }
242+
243+type cancelReader struct {
244+ cancel context.CancelFunc
245+ done bool
246+}
247+
248+func (r *cancelReader) Read(p []byte) (int, error) {
249+ if r.done {
250+ return 0, context.Canceled
251+ }
252+ r.done = true
253+ r.cancel()
254+ p[0] = 'x'
255+ return 1, nil
256+}
M internal/adapters/ciartifacts/ciartifacts.go +5 -171
@@ -1,23 +1,12 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-// Package ciartifacts implements ports.CIArtifactFiles under
4-// $ERMITE_DATA_DIR/ci/artifacts, keyed by the global run id like the logs (a
5-// repository rename never moves them):
6-//
7-// <run:020d>/<id:020d> the content of a committed artifact
8-// <run:020d>/<id:020d>.blocks/<f> the staged blocks of a pending one
9-// <run:020d>/.tmp-<id:020d>-* a content being assembled
10-//
11-// Paths are made of ids and of block file names the use case builds from
12-// hexadecimal characters: an artifact name never reaches the file system.
13-// Files are 0600 in 0700 directories, synced before they count, and a content
14-// appears by rename, whole or not at all.
3+// Package ciartifacts reads and removes legacy artifacts recorded before the
4+// blob store (ADR 0034), under $ERMITE_DATA_DIR/ci/artifacts. Paths use ids
5+// only. The adapter is removed once all legacy artifacts have expired.
156 package ciartifacts
167
178 import (
189 "context"
19- "crypto/sha256"
20- "encoding/hex"
2110 "errors"
2211 "fmt"
2312 "io"
@@ -39,9 +28,6 @@ const (
3928 idFormat = "%020d"
4029 blocksSuffix = ".blocks"
4130 tmpPrefix = ".tmp-"
42- // maxFileName bounds a block file name (well under the 255 bytes of a
43- // name on Linux).
44- maxFileName = 200
4531 )
4632
4733 // Store is the file-backed artifact store.
@@ -54,15 +40,6 @@ func New(dir string) *Store { return &Store{root: dir} }
5440
5541 func validRef(ref ports.CIArtifactRef) bool { return ref.RunID > 0 && ref.ID > 0 }
5642
57-// validFile accepts the block file names of the use case: lowercase
58-// hexadecimal characters and dots, not starting with a dot.
59-func validFile(name string) bool {
60- if name == "" || len(name) > maxFileName || name[0] == '.' {
61- return false
62- }
63- return strings.Trim(name, "0123456789abcdef.") == ""
64-}
65-
6643 func (s *Store) runDir(runID int64) string {
6744 return filepath.Join(s.root, fmt.Sprintf(idFormat, runID))
6845 }
@@ -75,138 +52,6 @@ func (s *Store) blocksDir(ref ports.CIArtifactRef) string {
7552 return s.contentPath(ref) + blocksSuffix
7653 }
7754
78-// syncDir makes the entries of a directory durable.
79-func syncDir(dir string) error {
80- d, err := os.Open(dir)
81- if err != nil {
82- return err
83- }
84- serr := d.Sync()
85- return errors.Join(serr, d.Close())
86-}
87-
88-// ctxReader stops a copy when its context ends.
89-type ctxReader struct {
90- ctx context.Context
91- r io.Reader
92-}
93-
94-func (c ctxReader) Read(p []byte) (int, error) {
95- if err := c.ctx.Err(); err != nil {
96- return 0, err
97- }
98- return c.r.Read(p)
99-}
100-
101-// PutBlock writes a new block file, created exclusively.
102-func (s *Store) PutBlock(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error) {
103- if !validRef(ref) || !validFile(file) {
104- return 0, fmt.Errorf("%w: block file %q", domain.ErrInvalid, file)
105- }
106- dir := s.blocksDir(ref)
107- if err := os.MkdirAll(dir, 0o700); err != nil {
108- return 0, fmt.Errorf("create block directory: %w", err)
109- }
110- path := filepath.Join(dir, file)
111- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
112- if err != nil {
113- return 0, fmt.Errorf("create block: %w", err)
114- }
115- n, werr := io.Copy(f, ctxReader{ctx, src})
116- var serr error
117- if werr == nil {
118- serr = f.Sync()
119- }
120- cerr := f.Close()
121- if err := errors.Join(werr, serr, cerr); err != nil {
122- _ = os.Remove(path)
123- if werr != nil {
124- return 0, werr // the source's error, as it is
125- }
126- return 0, fmt.Errorf("write block: %w", err)
127- }
128- if err := syncDir(dir); err != nil {
129- _ = os.Remove(path)
130- return 0, fmt.Errorf("sync block directory: %w", err)
131- }
132- return n, nil
133-}
134-
135-// RemoveBlock deletes one block file.
136-func (s *Store) RemoveBlock(_ context.Context, ref ports.CIArtifactRef, file string) error {
137- if !validRef(ref) || !validFile(file) {
138- return nil
139- }
140- if err := os.Remove(filepath.Join(s.blocksDir(ref), file)); err != nil && !errors.Is(err, fs.ErrNotExist) {
141- return fmt.Errorf("remove block: %w", err)
142- }
143- return nil
144-}
145-
146-// Assemble writes the blocks into a temporary file, syncs it and renames it
147-// over the content.
148-func (s *Store) Assemble(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error) {
149- if !validRef(ref) {
150- return 0, "", domain.ErrNotFound
151- }
152- for _, f := range files {
153- if !validFile(f) {
154- return 0, "", fmt.Errorf("%w: block file %q", domain.ErrInvalid, f)
155- }
156- }
157- dir := s.runDir(ref.RunID)
158- if err := os.MkdirAll(dir, 0o700); err != nil {
159- return 0, "", fmt.Errorf("create artifact directory: %w", err)
160- }
161- out, err := os.CreateTemp(dir, tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*")
162- if err != nil {
163- return 0, "", fmt.Errorf("create artifact: %w", err)
164- }
165- tmp := out.Name()
166- h := sha256.New()
167- size, err := s.concat(ctx, ref, files, io.MultiWriter(out, h))
168- if err == nil {
169- err = out.Sync()
170- }
171- if cerr := out.Close(); err == nil {
172- err = cerr
173- }
174- if err == nil {
175- err = os.Rename(tmp, s.contentPath(ref))
176- }
177- if err != nil {
178- _ = os.Remove(tmp)
179- if errors.Is(err, domain.ErrNotFound) || ctx.Err() != nil {
180- return 0, "", err
181- }
182- return 0, "", fmt.Errorf("assemble artifact: %w", err)
183- }
184- if err := syncDir(dir); err != nil {
185- return 0, "", fmt.Errorf("sync artifact directory: %w", err)
186- }
187- return size, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
188-}
189-
190-func (s *Store) concat(ctx context.Context, ref ports.CIArtifactRef, files []string, w io.Writer) (int64, error) {
191- var size int64
192- for _, name := range files {
193- f, err := os.Open(filepath.Join(s.blocksDir(ref), name))
194- if errors.Is(err, fs.ErrNotExist) {
195- return 0, fmt.Errorf("%w: block file %s", domain.ErrNotFound, name)
196- }
197- if err != nil {
198- return 0, err
199- }
200- n, err := io.Copy(w, ctxReader{ctx, f})
201- _ = f.Close()
202- if err != nil {
203- return 0, err
204- }
205- size += n
206- }
207- return size, nil
208-}
209-
21055 // Open opens the content at offset.
21156 func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) {
21257 if !validRef(ref) || offset < 0 {
@@ -227,7 +72,7 @@ func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (
22772 }
22873
22974 // Remove deletes the content, the blocks and any assembly in progress.
230-func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
75+func (s *Store) Remove(_ context.Context, ref ports.CIArtifactRef) error {
23176 if !validRef(ref) {
23277 return nil
23378 }
@@ -235,7 +80,7 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
23580 if err := os.Remove(s.contentPath(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) {
23681 errs = append(errs, err)
23782 }
238- errs = append(errs, s.RemoveBlocks(ctx, ref))
83+ errs = append(errs, os.RemoveAll(s.blocksDir(ref)))
23984 tmps, _ := filepath.Glob(filepath.Join(s.runDir(ref.RunID), tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*"))
24085 for _, t := range tmps {
24186 if err := os.Remove(t); err != nil && !errors.Is(err, fs.ErrNotExist) {
@@ -248,17 +93,6 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
24893 return nil
24994 }
25095
251-// RemoveBlocks deletes the staging directory of the artifact.
252-func (s *Store) RemoveBlocks(_ context.Context, ref ports.CIArtifactRef) error {
253- if !validRef(ref) {
254- return nil
255- }
256- if err := os.RemoveAll(s.blocksDir(ref)); err != nil {
257- return fmt.Errorf("remove blocks: %w", err)
258- }
259- return nil
260-}
261-
26296 // RemoveRuns deletes the directories of the runs.
26397 func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
26498 var errs []error
M internal/adapters/ciartifacts/ciartifacts_test.go +8 -105
@@ -3,13 +3,10 @@
33 package ciartifacts_test
44
55 import (
6- "crypto/sha256"
7- "encoding/hex"
86 "errors"
97 "io"
108 "os"
119 "path/filepath"
12- "strings"
1310 "testing"
1411
1512 "github.com/stretchr/testify/assert"
@@ -22,11 +19,6 @@ import (
2219
2320 var ref = ports.CIArtifactRef{RunID: 7, ID: 42}
2421
25-func digestOf(s string) string {
26- sum := sha256.Sum256([]byte(s))
27- return "sha256:" + hex.EncodeToString(sum[:])
28-}
29-
3022 func entries(t *testing.T, dir string) []string {
3123 t.Helper()
3224 es, err := os.ReadDir(dir)
@@ -41,121 +33,32 @@ func entries(t *testing.T, dir string) []string {
4133 return out
4234 }
4335
44-func TestBlocksAssembleInTheOrderOfTheList(t *testing.T) {
36+func TestLegacyReadAndRemoval(t *testing.T) {
4537 ctx := t.Context()
4638 root := t.TempDir()
4739 s := ciartifacts.New(root)
48-
49- // Uploaded out of order; the list decides.
50- for file, data := range map[string]string{"02.aa": "world", "01.bb": "hello ", "03.cc": "!"} {
51- n, err := s.PutBlock(ctx, ref, file, strings.NewReader(data))
52- require.NoError(t, err)
53- assert.EqualValues(t, len(data), n)
54- }
55- size, digest, err := s.Assemble(ctx, ref, []string{"01.bb", "02.aa", "03.cc", "03.cc"})
56- require.NoError(t, err)
57- assert.EqualValues(t, len("hello world!!"), size)
58- assert.Equal(t, digestOf("hello world!!"), digest, "a repeated id is repeated content")
59-
60- // The documented layout, named by ids only.
61- b, err := os.ReadFile(filepath.Join(root, "00000000000000000007", "00000000000000000042"))
62- require.NoError(t, err)
63- assert.Equal(t, "hello world!!", string(b))
64- for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
65- assert.False(t, strings.HasPrefix(e, ".tmp-"), "no temporary file left: %s", e)
66- }
67-
68- // Offset reads.
40+ run := filepath.Join(root, "00000000000000000007")
41+ require.NoError(t, os.MkdirAll(run, 0o700))
42+ require.NoError(t, os.WriteFile(filepath.Join(run, "00000000000000000042"), []byte("hello world"), 0o600))
6943 r, err := s.Open(ctx, ref, 6)
7044 require.NoError(t, err)
71- rest, err := io.ReadAll(r)
45+ data, err := io.ReadAll(r)
7246 require.NoError(t, err)
7347 require.NoError(t, r.Close())
74- assert.Equal(t, "world!!", string(rest))
75-
76- // A new assembly replaces the content whole.
77- size, digest, err = s.Assemble(ctx, ref, nil)
78- require.NoError(t, err)
79- assert.Zero(t, size, "an empty list is an empty content")
80- assert.Equal(t, digestOf(""), digest)
81-
48+ assert.Equal(t, "world", string(data))
8249 ids, err := s.ArtifactIDs(ctx, 7)
8350 require.NoError(t, err)
8451 assert.Equal(t, []int64{42}, ids)
85- runs, err := s.RunIDs(ctx)
86- require.NoError(t, err)
87- assert.Equal(t, []int64{7}, runs)
88-
89- require.NoError(t, s.RemoveBlocks(ctx, ref))
90- assert.Equal(t, []string{"00000000000000000042"}, entries(t, filepath.Join(root, "00000000000000000007")))
9152 require.NoError(t, s.Remove(ctx, ref))
92- assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007")))
53+ require.NoError(t, s.Remove(ctx, ref))
9354 _, err = s.Open(ctx, ref, 0)
9455 assert.ErrorIs(t, err, domain.ErrNotFound)
9556 require.NoError(t, s.RemoveRuns(ctx, []int64{7}))
9657 assert.Empty(t, entries(t, root))
97-}
98-
99-func TestAMissingBlockIsNotFoundAndLeavesNoContent(t *testing.T) {
100- ctx := t.Context()
101- root := t.TempDir()
102- s := ciartifacts.New(root)
103- _, err := s.PutBlock(ctx, ref, "01.aa", strings.NewReader("x"))
104- require.NoError(t, err)
105- _, _, err = s.Assemble(ctx, ref, []string{"01.aa", "02.aa"})
106- assert.ErrorIs(t, err, domain.ErrNotFound)
107- _, err = s.Open(ctx, ref, 0)
108- assert.ErrorIs(t, err, domain.ErrNotFound, "no content appears")
109- for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
110- assert.False(t, strings.HasPrefix(e, ".tmp-"), e)
111- }
112-}
113-
114-type failingReader struct{ n int }
115-
116-func (f *failingReader) Read(p []byte) (int, error) {
117- if f.n == 0 {
118- return 0, errors.New("stream broken")
119- }
120- f.n--
121- p[0] = 'x'
122- return 1, nil
123-}
124-
125-func TestABrokenSourceLeavesNoBlock(t *testing.T) {
126- ctx := t.Context()
127- root := t.TempDir()
128- s := ciartifacts.New(root)
129- _, err := s.PutBlock(ctx, ref, "01.aa", &failingReader{n: 3})
130- require.EqualError(t, err, "stream broken", "the source's error as it is")
131- assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007", "00000000000000000042.blocks")))
132-
133- // A file name is never reused: the second put of a name fails.
134- _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("a"))
135- require.NoError(t, err)
136- _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("b"))
137- assert.Error(t, err)
138-}
139-
140-func TestNamesOutsideTheRulesNeverReachTheFileSystem(t *testing.T) {
141- ctx := t.Context()
142- root := t.TempDir()
143- s := ciartifacts.New(filepath.Join(root, "store"))
144- for _, name := range []string{"", "../x", "a/b", ".hidden", "..", "ZZ", "01.aa/../../x", strings.Repeat("a", 201)} {
145- _, err := s.PutBlock(ctx, ref, name, strings.NewReader("x"))
146- assert.ErrorIs(t, err, domain.ErrInvalid, "%q", name)
147- _, _, err = s.Assemble(ctx, ref, []string{name})
148- assert.Error(t, err, "%q", name)
149- }
15058 for _, bad := range []ports.CIArtifactRef{{RunID: 0, ID: 1}, {RunID: 1, ID: -1}} {
151- _, err := s.PutBlock(ctx, bad, "01.aa", strings.NewReader("x"))
152- assert.ErrorIs(t, err, domain.ErrInvalid)
153- _, err = s.Open(ctx, bad, 0)
59+ _, err := s.Open(ctx, bad, 0)
15460 assert.ErrorIs(t, err, domain.ErrNotFound)
15561 }
156- for _, e := range entries(t, root) {
157- assert.Equal(t, "store", e, "nothing written beside the store")
158- }
15962 }
16063
16164 func TestOrphanListingSeesEveryKindOfFile(t *testing.T) {
M internal/adapters/client/client.go +5 -0
@@ -80,6 +80,11 @@ func (c *Client) Artifacts() ermiteconnect.ArtifactServiceClient {
8080 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)
8181 }
8282
83+// Releases returns the ReleaseService client.
84+func (c *Client) Releases() ermiteconnect.ReleaseServiceClient {
85+ return ermiteconnect.NewReleaseServiceClient(c.http, c.base, c.opts...)
86+}
87+
8388 // Tokens returns the TokenService client.
8489 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
8590 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
142+ []string{client.FeatureReleases, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
145+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +3 -1
@@ -12,6 +12,8 @@ const (
1212 FeatureMarkReady = "merge_requests.ready"
1313 // FeatureIgnoreChecks permits merging with checks bypassed.
1414 FeatureIgnoreChecks = "merge_requests.ignore_checks"
15+ // FeatureReleases is ReleaseService (`ermite release`).
16+ FeatureReleases = "releases"
1517 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
1618 FeatureRepoUpdate = "repo.update"
1719 // FeatureEventsResume is EventService.Watch with resume_after, which
@@ -69,7 +71,7 @@ const (
6971
7072 // UsedFeatures lists the features the CLI uses, in the order they are reported.
7173 func UsedFeatures() []string {
72- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
74+ return []string{FeatureReleases, FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
7375 }
7476
7577 // MissingFeatures returns the features the CLI uses that have is lacking.
A internal/adapters/client/release_test.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package client_test
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+ "bnema.dev/ermite/internal/adapters/client"
18+)
19+
20+type releaseAPI struct {
21+ ermiteconnect.UnimplementedReleaseServiceHandler
22+}
23+
24+func (releaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
25+ if r.Header().Get("Authorization") != "Bearer secret" || r.Header().Get("User-Agent") != "ermite/test" {
26+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
27+ }
28+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: &ermite.Release{Id: r.Msg.GetId(), Repo: r.Msg.GetRepo()}}), nil
29+}
30+func TestReleasesAccessor(t *testing.T) {
31+ mux := http.NewServeMux()
32+ p, h := ermiteconnect.NewReleaseServiceHandler(releaseAPI{})
33+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
34+ srv := httptest.NewServer(mux)
35+ t.Cleanup(srv.Close)
36+ c, err := client.New(client.Config{URL: srv.URL, Token: "secret", UserAgent: "ermite/test"})
37+ require.NoError(t, err)
38+ res, err := c.Releases().GetRelease(t.Context(), connect.NewRequest(&ermite.GetReleaseRequest{Repo: "proj", Selector: &ermite.GetReleaseRequest_Id{Id: 7}}))
39+ require.NoError(t, err)
40+ assert.Equal(t, int64(7), res.Msg.GetRelease().GetId())
41+ assert.Equal(t, "proj", res.Msg.GetRelease().GetRepo())
42+ assert.Contains(t, client.UsedFeatures(), client.FeatureReleases)
43+}
M internal/adapters/client/scopes_test.go +1 -0
@@ -28,6 +28,7 @@ func TestScopeWireNames(t *testing.T) {
2828 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
2929 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
3030 ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
31+ ermite.Scope_SCOPE_RELEASE_WRITE: "release:write",
3132 }
3233 for s, name := range want {
3334 assert.Equal(t, name, client.ScopeName(s))
A internal/adapters/gitcli/tags.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+var _ ports.ReleaseTagWriter = (*Git)(nil)
16+
17+// CreateTag creates a lightweight tag without ever moving an existing ref.
18+func (g *Git) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
19+ if _, err := domain.ParseReleaseTag(tag); err != nil {
20+ return err
21+ }
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return err
25+ }
26+ ref := "refs/tags/" + tag
27+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", ref, target.String(), domain.ZeroOID)
28+ if err != nil {
29+ // Distinguish a concurrent creator from an unrelated Git failure. ResolveRevision
30+ // peels tags, so use show-ref to also recognize tags pointing at non-commits.
31+ if _, existsErr := g.run(ctx, "--git-dir="+dir, "show-ref", "--verify", "--", ref); existsErr == nil {
32+ return fmt.Errorf("tag %q in %q: %w", tag, repo, domain.ErrAlreadyExists)
33+ }
34+ if errors.Is(err, context.Canceled) {
35+ return err
36+ }
37+ return fmt.Errorf("create tag %q in %q: %w", tag, repo, err)
38+ }
39+ log := zerowrap.FromCtx(ctx)
40+ log.Info().Str("repo", repo.String()).Str("tag", tag).Msg("release tag created")
41+ return nil
42+}
A internal/adapters/gitcli/tags_test.go +31 -0
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCreateReleaseTag(t *testing.T) {
14+ g, data := newGit(t)
15+ ctx := context.Background()
16+ m := newMergeFixture(t, data)
17+ t.Cleanup(func() { _ = g.Close(ctx) })
18+ require.NoError(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.a))
19+ rev, err := domain.ParseRevision("refs/tags/v1.0")
20+ require.NoError(t, err)
21+ got, err := g.ResolveRevision(ctx, m.f.Name, rev)
22+ require.NoError(t, err)
23+ require.Equal(t, m.a, got.CommitID)
24+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.d), domain.ErrAlreadyExists)
25+ got, err = g.ResolveRevision(ctx, m.f.Name, rev)
26+ require.NoError(t, err)
27+ require.Equal(t, m.a, got.CommitID)
28+ for _, tag := range []string{"", "../bad", "bad^{commit}", "bad\\name"} {
29+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, tag, m.d), domain.ErrInvalid, tag)
30+ }
31+}
M internal/adapters/githubapi/handler.go +23 -2
@@ -68,6 +68,10 @@ const (
6868 type Config struct {
6969 Auth ports.AuthService
7070 API ports.GitHubAPIService
71+ // Releases and ReleaseDownload enable the release REST routes. Nil services
72+ // leave their routes unavailable with GitHub's 404 JSON.
73+ Releases ports.GitHubReleaseAPI
74+ ReleaseDownload ports.ReleaseService
7175 // TrustedProxies are the networks allowed to set X-Forwarded-For.
7276 TrustedProxies []netip.Prefix
7377 // Limiter counts failed authentications; nil builds a default one.
@@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124128 notFound(w)
125129 return
126130 }
127- if r.Method != http.MethodGet && r.Method != http.MethodHead {
128- w.Header().Set("Allow", "GET, HEAD")
131+ if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil {
132+ notFound(w)
133+ return
134+ }
135+ allowed := releaseMethods(kind)
136+ if !methodAllowed(r.Method, allowed) {
137+ w.Header().Set("Allow", allowed)
129138 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
130139 return
131140 }
@@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148157 return
149158 }
150159
160+ if strings.HasPrefix(kind, "release") {
161+ h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr))
162+ return
163+ }
151164 switch kind {
152165 case "repo":
153166 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
@@ -172,6 +185,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172185
173186 // route recognises the endpoints. ref is "" for /tarball.
174187 func route(rest string) (kind, owner, repo, ref string, ok bool) {
188+ // go-github's enterprise client (used by GoReleaser) appends api/v3
189+ // to a custom API base. Keep the canonical URLs, accepting that alias too.
190+ if strings.HasPrefix(rest, "/api/v3/") {
191+ rest = strings.TrimPrefix(rest, "/api/v3")
192+ }
193+ if kind, owner, repo, ref, ok = releaseRoute(rest); ok {
194+ return
195+ }
175196 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
176197 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
177198 return "", "", "", "", false
A internal/adapters/githubapi/releases.go +413 -0
@@ -0,0 +1,413 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "mime"
11+ "net/http"
12+ "strconv"
13+ "strings"
14+ "unicode"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/httprange"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/bnema/zerowrap"
20+)
21+
22+func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) {
23+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
24+ upload := len(parts) > 0 && parts[0] == "uploads"
25+ if upload {
26+ parts = parts[1:]
27+ }
28+ if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" {
29+ return
30+ }
31+ owner, repo = parts[1], parts[2]
32+ if upload {
33+ if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" {
34+ return "release-upload", owner, repo, parts[4], true
35+ }
36+ return
37+ }
38+ switch {
39+ case len(parts) == 4:
40+ return "release-list", owner, repo, "", true
41+ case len(parts) == 5 && parts[4] == "latest":
42+ return "release-latest", owner, repo, "", true
43+ case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "":
44+ return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true
45+ case len(parts) == 5 && isNumber(parts[4]):
46+ return "release-id", owner, repo, parts[4], true
47+ case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets":
48+ return "release-assets", owner, repo, parts[4], true
49+ case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]):
50+ return "release-asset", owner, repo, parts[5], true
51+ }
52+ return
53+}
54+func releaseMethods(kind string) string {
55+ switch kind {
56+ case "release-list":
57+ return "GET, HEAD, POST"
58+ case "release-id", "release-asset":
59+ return "GET, HEAD, PATCH, DELETE"
60+ case "release-upload":
61+ return "POST"
62+ default:
63+ return "GET, HEAD"
64+ }
65+}
66+func methodAllowed(method, allow string) bool {
67+ for _, m := range strings.Split(allow, ", ") {
68+ if method == m {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) {
75+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
76+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
77+ w.WriteHeader(status)
78+ if r.Method != http.MethodHead {
79+ _, _ = w.Write(body)
80+ }
81+}
82+func releaseMessage(w http.ResponseWriter, status int, message string) {
83+ b, _ := json.Marshal(struct {
84+ Message string `json:"message"`
85+ }{message})
86+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
87+ w.WriteHeader(status)
88+ _, _ = w.Write(b)
89+}
90+func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
91+ b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
92+ if err != nil {
93+ var tooLarge *http.MaxBytesError
94+ if errors.As(err, &tooLarge) {
95+ releaseMessage(w, 422, "Body too large")
96+ } else {
97+ releaseMessage(w, 422, "Validation Failed")
98+ }
99+ return nil, false
100+ }
101+ return b, true
102+}
103+func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) {
104+ ctx := r.Context()
105+ api := h.cfg.Releases
106+ id, _ := strconv.ParseInt(ref, 10, 64)
107+ q := r.URL.Query()
108+ page, _ := strconv.Atoi(q.Get("page"))
109+ perPage, _ := strconv.Atoi(q.Get("per_page"))
110+ var body []byte
111+ var err error
112+ var link string
113+ status := http.StatusOK
114+ if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") {
115+ if h.cfg.ReleaseDownload == nil {
116+ notFound(w)
117+ return
118+ }
119+ h.downloadRelease(w, r, p, owner, repo, id, client)
120+ return
121+ }
122+ switch kind {
123+ case "release-list":
124+ if r.Method == http.MethodPost {
125+ b, ok := releaseBody(w, r)
126+ if !ok {
127+ return
128+ }
129+ body, err = api.CreateRelease(ctx, p, owner, repo, b)
130+ status = 201
131+ } else {
132+ body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage)
133+ }
134+ case "release-latest":
135+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true})
136+ case "release-tag":
137+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref})
138+ case "release-id":
139+ switch r.Method {
140+ case http.MethodPatch:
141+ b, ok := releaseBody(w, r)
142+ if !ok {
143+ return
144+ }
145+ body, err = api.UpdateRelease(ctx, p, owner, repo, id, b)
146+ case http.MethodDelete:
147+ err = api.DeleteRelease(ctx, p, owner, repo, id)
148+ status = 204
149+ default:
150+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id})
151+ }
152+ case "release-assets":
153+ body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage)
154+ case "release-upload":
155+ if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") {
156+ releaseMessage(w, 411, "Length Required")
157+ return
158+ }
159+ if q.Get("name") == "" {
160+ h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name")
161+ return
162+ }
163+ ct := r.Header.Get("Content-Type")
164+ if ct == "" {
165+ ct = "application/octet-stream"
166+ }
167+ body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength))
168+ status = 201
169+ case "release-asset":
170+ switch r.Method {
171+ case http.MethodPatch:
172+ b, ok := releaseBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ body, err = api.UpdateAsset(ctx, p, owner, repo, id, b)
177+ case http.MethodDelete:
178+ err = api.DeleteAsset(ctx, p, owner, repo, id)
179+ status = 204
180+ default:
181+ body, err = api.GetAsset(ctx, p, owner, repo, id)
182+ }
183+ }
184+ if err != nil {
185+ h.failRelease(ctx, w, p, err, kind, "")
186+ return
187+ }
188+ if link != "" {
189+ w.Header().Set("Link", link)
190+ }
191+ if status == 204 {
192+ w.WriteHeader(status)
193+ return
194+ }
195+ releaseJSON(w, r, status, body)
196+}
197+
198+// failRelease translates use-case failures to GitHub's status and error JSON;
199+// authorization and limits have already been decided by ReleaseService.
200+//
201+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here
202+func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) {
203+ switch {
204+ case errors.Is(err, domain.ErrNotFound):
205+ notFound(w)
206+ case errors.Is(err, domain.ErrUnauthenticated):
207+ releaseMessage(w, 401, "Bad credentials")
208+ case errors.Is(err, domain.ErrForbidden):
209+ msg := "Forbidden"
210+ if p.ViaJobToken() {
211+ msg = "Resource not accessible by integration"
212+ }
213+ releaseMessage(w, 403, msg)
214+ case errors.Is(err, domain.ErrResourceExhausted):
215+ if strings.Contains(err.Error(), "too many uploads in progress") {
216+ w.Header().Set("Retry-After", retryAfter)
217+ releaseMessage(w, 429, err.Error())
218+ } else {
219+ releaseMessage(w, 422, err.Error())
220+ }
221+ case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed):
222+ writeReleaseValidation(w, err, kind, field)
223+ case errors.Is(err, context.Canceled):
224+ default:
225+ log := zerowrap.FromCtx(ctx)
226+ log.Error().Err(err).Msg("github release request failed")
227+ releaseMessage(w, 500, "Server Error")
228+ }
229+}
230+
231+type releaseValidationError struct {
232+ Resource string `json:"resource"`
233+ Code string `json:"code"`
234+ Field string `json:"field,omitempty"`
235+}
236+
237+func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) {
238+ resource, code, doc := "Release", "invalid", ""
239+ asset := kind == "release-upload" || kind == "release-asset"
240+ if asset {
241+ resource = "ReleaseAsset"
242+ }
243+ if errors.Is(err, domain.ErrAlreadyExists) {
244+ code = "already_exists"
245+ field = "tag_name"
246+ if asset {
247+ field = "name"
248+ }
249+ if kind == "release-upload" {
250+ doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset"
251+ }
252+ } else if errors.Is(err, domain.ErrPreconditionFailed) {
253+ field = "target_commitish"
254+ } else if field == "" {
255+ field = releaseErrorField(err.Error())
256+ }
257+ b, _ := json.Marshal(struct {
258+ Message string `json:"message"`
259+ Errors []releaseValidationError `json:"errors"`
260+ DocumentationURL string `json:"documentation_url,omitempty"`
261+ }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc})
262+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
263+ w.WriteHeader(422)
264+ _, _ = w.Write(b)
265+}
266+
267+// releaseErrorField recognises the wire fields and domain validation vocabulary.
268+// Errors without a named field omit it rather than guessing.
269+func releaseErrorField(text string) string {
270+ for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} {
271+ if strings.Contains(text, f) {
272+ return f
273+ }
274+ }
275+ if strings.Contains(text, "tag name") {
276+ return "tag_name"
277+ }
278+ if strings.Contains(text, "media type") {
279+ return "content_type"
280+ }
281+ if strings.Contains(text, "name") {
282+ return "name"
283+ }
284+ return ""
285+}
286+
287+func releaseContentDisposition(name string) string {
288+ name = strings.Map(func(r rune) rune {
289+ if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
290+ return -1
291+ }
292+ return r
293+ }, name)
294+ if name == "" || name == "." || name == ".." {
295+ name = "asset"
296+ }
297+ ascii := true
298+ fallback := strings.Map(func(r rune) rune {
299+ if r > unicode.MaxASCII {
300+ ascii = false
301+ return '_'
302+ }
303+ return r
304+ }, name)
305+ // Quote even token-safe names, keeping a consistent RFC 6266 representation.
306+ cd := "attachment; filename=" + strconv.Quote(fallback)
307+ if !ascii {
308+ const digits = "0123456789ABCDEF"
309+ var b strings.Builder
310+ for i := 0; i < len(name); i++ {
311+ c := name[i]
312+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
313+ b.WriteByte(c)
314+ } else {
315+ b.WriteByte('%')
316+ b.WriteByte(digits[c>>4])
317+ b.WriteByte(digits[c&15])
318+ }
319+ }
320+ cd += "; filename*=UTF-8''" + b.String()
321+ }
322+ return cd
323+}
324+func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string {
325+ ct := a.ContentType
326+ media, _, err := mime.ParseMediaType(ct)
327+ media = strings.ToLower(media)
328+ if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" {
329+ ct = "application/octet-stream"
330+ }
331+ w.Header().Set("Content-Type", ct)
332+ w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name))
333+ w.Header().Set("X-Content-Type-Options", "nosniff")
334+ w.Header().Set("Content-Security-Policy", "sandbox")
335+ w.Header().Set("Accept-Ranges", "bytes")
336+ etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"`
337+ w.Header().Set("ETag", etag)
338+ w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat))
339+ return etag
340+}
341+func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) {
342+ ctx := r.Context()
343+ a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id)
344+ if err != nil {
345+ h.failRelease(ctx, w, p, err, "release-asset", "")
346+ return
347+ }
348+ if r.Method != http.MethodHead {
349+ release, ok := h.acquire(w, client)
350+ if !ok {
351+ return
352+ }
353+ defer release()
354+ }
355+ etag := setReleaseDownloadHeaders(w, a)
356+ if r.Header.Get("If-None-Match") == etag {
357+ w.WriteHeader(304)
358+ return
359+ }
360+ offset, length, partial, err := httprange.Select(r, a.Size, etag)
361+ if err != nil {
362+ w.Header().Set("Content-Range", httprange.Unsatisfied(a.Size))
363+ w.WriteHeader(416)
364+ return
365+ }
366+ status := 200
367+ if partial {
368+ status = 206
369+ w.Header().Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
370+ }
371+ w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
372+ if r.Method == http.MethodHead {
373+ w.WriteHeader(status)
374+ return
375+ }
376+ // Slot failures and pre-stream failures are JSON, not a binary response.
377+ clearHeaders := func() {
378+ for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} {
379+ w.Header().Del(k)
380+ }
381+ }
382+ writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle}
383+ started := false
384+ err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error {
385+ if len(chunk.Data) == 0 {
386+ return nil
387+ }
388+ if !started {
389+ w.WriteHeader(status)
390+ started = true
391+ }
392+ _, e := writer.Write(chunk.Data)
393+ return e
394+ })
395+ if err == nil {
396+ if !started {
397+ w.WriteHeader(status)
398+ }
399+ return
400+ }
401+ if started {
402+ log := zerowrap.FromCtx(ctx)
403+ log.Warn().Err(err).Msg("github release download cut short")
404+ panic(http.ErrAbortHandler)
405+ }
406+ clearHeaders()
407+ if errors.Is(err, domain.ErrOutOfRange) {
408+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
409+ w.WriteHeader(416)
410+ return
411+ }
412+ h.failRelease(ctx, w, p, err, "release-asset", "")
413+}
A internal/adapters/githubapi/releases_test.go +466 -0
@@ -0,0 +1,466 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "net/url"
14+ "os"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+const releasePath = "/repos/acme/proj/releases"
27+const releaseAssetPath = releasePath + "/assets/7"
28+const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets"
29+
30+func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
31+ t.Helper()
32+ req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body))
33+ for k, v := range headers {
34+ req.Header.Set(k, v)
35+ }
36+ w := httptest.NewRecorder()
37+ h.ServeHTTP(w, req)
38+ return w
39+}
40+func TestReleaseRoutes(t *testing.T) {
41+ cases := []struct{ path, kind, ref, allow string }{
42+ {releasePath, "release-list", "", "GET, HEAD, POST"},
43+ {releasePath + "/latest", "release-latest", "", "GET, HEAD"},
44+ {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"},
45+ {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"},
46+ {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"},
47+ {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"},
48+ {releaseUploadPath, "release-upload", "4", "POST"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.kind, func(t *testing.T) {
52+ kind, owner, repo, ref, ok := route(tc.path)
53+ require.True(t, ok)
54+ require.Equal(t, tc.kind, kind)
55+ require.Equal(t, "acme", owner)
56+ require.Equal(t, "proj", repo)
57+ require.Equal(t, tc.ref, ref)
58+ api := mocks.NewGitHubReleaseAPI(t)
59+ h := New(Config{Releases: api})
60+ for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} {
61+ if methodAllowed(method, tc.allow) {
62+ continue
63+ }
64+ w := releaseRequest(t, h, method, tc.path, "", nil)
65+ require.Equal(t, 405, w.Code)
66+ require.Equal(t, tc.allow, w.Header().Get("Allow"))
67+ }
68+ w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil)
69+ require.Equal(t, 404, w.Code)
70+ require.Equal(t, notFoundBody, w.Body.String())
71+ })
72+ }
73+ for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} {
74+ _, _, _, _, ok := route(path)
75+ require.False(t, ok, path)
76+ }
77+}
78+
79+// GoReleaser's go-github enterprise client appends api/v3 to the documented
80+// custom API URL. The alias must dispatch writes through the same auth/service.
81+func TestReleaseEnterpriseBase(t *testing.T) {
82+ api := mocks.NewGitHubReleaseAPI(t)
83+ h := New(Config{Releases: api})
84+ body := []byte(`{"tag_name":"v1"}`)
85+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", body).Return([]byte(`{"id":4}`), nil).Once()
86+ w := releaseRequest(t, h, "POST", "/api/v3"+releasePath, string(body), nil)
87+ require.Equal(t, 201, w.Code)
88+ require.JSONEq(t, `{"id":4}`, w.Body.String())
89+ for _, path := range []string{releasePath, releasePath + "/4", releasePath + "/4/assets", releaseAssetPath, releasePath + "/latest", releasePath + "/tags/v1/patch"} {
90+ kind, owner, repo, ref, ok := route(path)
91+ aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK := route("/api/v3" + path)
92+ require.Equal(t, []any{kind, owner, repo, ref, ok}, []any{aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK})
93+ }
94+ _, _, _, _, ok := route("/api/v3repos/acme/proj/releases")
95+ require.False(t, ok)
96+}
97+
98+func TestReleaseDispatch(t *testing.T) {
99+ for _, method := range []string{"GET", "HEAD"} {
100+ t.Run(method, func(t *testing.T) {
101+ api := mocks.NewGitHubReleaseAPI(t)
102+ h := New(Config{Releases: api})
103+ api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once()
104+ w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil)
105+ require.Equal(t, 200, w.Code)
106+ require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link"))
107+ require.Equal(t, "2", w.Header().Get("Content-Length"))
108+ if method == "HEAD" {
109+ require.Empty(t, w.Body.String())
110+ } else {
111+ require.Equal(t, "[]", w.Body.String())
112+ }
113+ for _, tc := range []struct {
114+ path string
115+ ref ports.ReleaseRef
116+ }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} {
117+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once()
118+ w = releaseRequest(t, h, method, tc.path, "", nil)
119+ require.Equal(t, 200, w.Code)
120+ }
121+ api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once()
122+ w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil)
123+ require.Equal(t, 200, w.Code)
124+ require.Equal(t, "asset-link", w.Header().Get("Link"))
125+ api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once()
126+ w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"})
127+ require.Equal(t, 200, w.Code)
128+ })
129+ }
130+ api := mocks.NewGitHubReleaseAPI(t)
131+ h := New(Config{Releases: api})
132+ b := []byte(`{"tag_name":"v1"}`)
133+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once()
134+ w := releaseRequest(t, h, "POST", releasePath, string(b), nil)
135+ require.Equal(t, 201, w.Code)
136+ require.Equal(t, `{"id":4}`, w.Body.String())
137+ api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once()
138+ w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil)
139+ require.Equal(t, 200, w.Code)
140+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once()
141+ w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil)
142+ require.Equal(t, 200, w.Code)
143+ api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once()
144+ w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil)
145+ require.Equal(t, 204, w.Code)
146+ require.Empty(t, w.Body.String())
147+ api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once()
148+ w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil)
149+ require.Equal(t, 204, w.Code)
150+ require.Empty(t, w.Body.String())
151+}
152+func TestReleaseUpload(t *testing.T) {
153+ api := mocks.NewGitHubReleaseAPI(t)
154+ h := New(Config{Releases: api})
155+ req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc"))
156+ req.ContentLength = -1
157+ w := httptest.NewRecorder()
158+ h.ServeHTTP(w, req)
159+ require.Equal(t, 411, w.Code)
160+ require.Equal(t, `{"message":"Length Required"}`, w.Body.String())
161+ // HTTP distinguishes an omitted length from an explicitly empty asset.
162+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil)
163+ require.Equal(t, 411, w.Code)
164+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once()
165+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"})
166+ require.Equal(t, 201, w.Code)
167+ w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil)
168+ require.Equal(t, 422, w.Code)
169+ require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
170+ for _, ct := range []string{"", "text/plain"} {
171+ expectedType := ct
172+ if ct == "" {
173+ expectedType = "application/octet-stream"
174+ }
175+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) {
176+ got, err := io.ReadAll(r)
177+ require.NoError(t, err)
178+ require.Equal(t, "abc", string(got))
179+ return []byte(`{"id":7}`), nil
180+ }).Once()
181+ req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef"))
182+ req.ContentLength = 3
183+ req.Header.Set("Content-Type", ct)
184+ w = httptest.NewRecorder()
185+ h.ServeHTTP(w, req)
186+ require.Equal(t, 201, w.Code)
187+ require.Equal(t, `{"id":7}`, w.Body.String())
188+ }
189+ // The real response template expands to precisely the upload route used above.
190+ b, err := os.ReadFile("testdata/releases/release.json")
191+ require.NoError(t, err)
192+ var object struct {
193+ UploadURL string `json:"upload_url"`
194+ }
195+ require.NoError(t, json.Unmarshal(b, &object))
196+ expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz")
197+ u, err := url.Parse(expanded)
198+ require.NoError(t, err)
199+ kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix))
200+ require.True(t, ok)
201+ require.Equal(t, "release-upload", kind)
202+ require.Equal(t, "acme", owner)
203+ require.Equal(t, "releases", repo)
204+ require.Equal(t, "1", ref)
205+}
206+func TestReleaseBodyLimit(t *testing.T) {
207+ h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)})
208+ for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} {
209+ w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil)
210+ require.Equal(t, 422, w.Code)
211+ require.Equal(t, `{"message":"Body too large"}`, w.Body.String())
212+ }
213+}
214+func TestReleaseErrors(t *testing.T) {
215+ rn, err := domain.ParseRepoName("proj")
216+ require.NoError(t, err)
217+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
218+ cases := []struct {
219+ name, kind string
220+ p domain.Principal
221+ err error
222+ status int
223+ body string
224+ }{
225+ {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody},
226+ {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`},
227+ {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`},
228+ {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`},
229+ {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`},
230+ {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`},
231+ {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`},
232+ {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`},
233+ {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`},
234+ {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`},
235+ {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`},
236+ {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`},
237+ {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`},
238+ {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`},
239+ }
240+ for _, tc := range cases {
241+ t.Run(tc.name, func(t *testing.T) {
242+ w := httptest.NewRecorder()
243+ New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "")
244+ require.Equal(t, tc.status, w.Code)
245+ require.Equal(t, tc.body, w.Body.String())
246+ if tc.status == 429 {
247+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
248+ }
249+ })
250+ }
251+ // Verify the route actually forwards a use-case visibility failure unchanged.
252+ api := mocks.NewGitHubReleaseAPI(t)
253+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once()
254+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil)
255+ require.Equal(t, 404, w.Code)
256+ require.Equal(t, notFoundBody, w.Body.String())
257+}
258+func downloadAsset(t *testing.T) domain.ReleaseAsset {
259+ t.Helper()
260+ rn, err := domain.ParseRepoName("proj")
261+ require.NoError(t, err)
262+ return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)}
263+}
264+func TestReleaseDownload(t *testing.T) {
265+ cases := []struct {
266+ name, method, rang, etag string
267+ status int
268+ offset, length int64
269+ body, contentRange string
270+ }{
271+ {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""},
272+ {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"},
273+ {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"},
274+ {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"},
275+ {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"},
276+ {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"},
277+ {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""},
278+ {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""},
279+ {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"},
280+ {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"},
281+ {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"},
282+ {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"},
283+ {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""},
284+ {"head", "HEAD", "", "", 200, 0, 10, "", ""},
285+ {"head ignores range", "HEAD", "bytes=2-5", "", 200, 0, 10, "", ""},
286+ }
287+ for _, tc := range cases {
288+ t.Run(tc.name, func(t *testing.T) {
289+ api := mocks.NewGitHubReleaseAPI(t)
290+ dl := mocks.NewReleaseService(t)
291+ a := downloadAsset(t)
292+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
293+ if tc.method == "GET" && (tc.status == 200 || tc.status == 206) {
294+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
295+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
296+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
297+ }).Once()
298+ }
299+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag})
300+ require.Equal(t, tc.status, w.Code)
301+ require.Equal(t, tc.body, w.Body.String())
302+ require.Equal(t, tc.contentRange, w.Header().Get("Content-Range"))
303+ require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
304+ require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition"))
305+ require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
306+ require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy"))
307+ require.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
308+ require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag"))
309+ require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified"))
310+ if tc.status == 200 || tc.status == 206 {
311+ require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length"))
312+ }
313+ })
314+ }
315+}
316+func TestReleaseDownloadSafety(t *testing.T) {
317+ for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} {
318+ a := downloadAsset(t)
319+ a.ContentType = ct
320+ a.Name = "é space.txt"
321+ w := httptest.NewRecorder()
322+ setReleaseDownloadHeaders(w, a)
323+ require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
324+ require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition"))
325+ }
326+ require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt"))
327+}
328+func TestReleaseDownloadFailures(t *testing.T) {
329+ for _, started := range []bool{false, true} {
330+ t.Run(fmt.Sprint(started), func(t *testing.T) {
331+ api := mocks.NewGitHubReleaseAPI(t)
332+ dl := mocks.NewReleaseService(t)
333+ a := downloadAsset(t)
334+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
335+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
336+ if started {
337+ require.NoError(t, send(ports.AssetChunk{Data: []byte("x")}))
338+ }
339+ return domain.ErrNotFound
340+ }).Once()
341+ h := New(Config{Releases: api, ReleaseDownload: dl})
342+ run := func() {
343+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
344+ require.Equal(t, 404, w.Code)
345+ require.Equal(t, notFoundBody, w.Body.String())
346+ require.Empty(t, w.Header().Get("Content-Length"))
347+ require.Empty(t, w.Header().Get("Content-Range"))
348+ }
349+ if started {
350+ require.PanicsWithValue(t, http.ErrAbortHandler, run)
351+ } else {
352+ run()
353+ }
354+ require.Empty(t, h.archives)
355+ require.Empty(t, h.clients)
356+ })
357+ }
358+}
359+func TestReleaseDownloadSlots(t *testing.T) {
360+ api := mocks.NewGitHubReleaseAPI(t)
361+ a := downloadAsset(t)
362+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2)
363+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1})
364+ release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1")
365+ require.True(t, ok)
366+ defer release()
367+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
368+ require.Equal(t, 429, w.Code)
369+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
370+ require.Empty(t, w.Header().Get("Content-Length"))
371+ require.Contains(t, w.Body.String(), "Too many archives")
372+ // A HEAD uses metadata, even when every download slot is taken.
373+ w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
374+ require.Equal(t, 200, w.Code)
375+}
376+
377+func TestReleaseEmptyAndMissingDownload(t *testing.T) {
378+ for _, tc := range []struct {
379+ name string
380+ size int64
381+ err error
382+ want int
383+ }{
384+ {"empty", 0, nil, 200},
385+ {"missing empty", 0, domain.ErrNotFound, 404},
386+ {"store range", 10, domain.ErrOutOfRange, 416},
387+ } {
388+ t.Run(tc.name, func(t *testing.T) {
389+ api := mocks.NewGitHubReleaseAPI(t)
390+ dl := mocks.NewReleaseService(t)
391+ a := downloadAsset(t)
392+ a.Size = tc.size
393+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
394+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once()
395+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
396+ require.Equal(t, tc.want, w.Code)
397+ switch tc.want {
398+ case 200:
399+ require.Equal(t, "0", w.Header().Get("Content-Length"))
400+ require.Empty(t, w.Body.String())
401+ case 416:
402+ require.Equal(t, "bytes */10", w.Header().Get("Content-Range"))
403+ case 404:
404+ require.Equal(t, notFoundBody, w.Body.String())
405+ }
406+ })
407+ }
408+ // Missing the stream service leaves JSON available but bytes disabled.
409+ api := mocks.NewGitHubReleaseAPI(t)
410+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
411+ require.Equal(t, 404, w.Code)
412+ require.Equal(t, notFoundBody, w.Body.String())
413+}
414+
415+func TestReleaseDownloadGlobalSlot(t *testing.T) {
416+ api := mocks.NewGitHubReleaseAPI(t)
417+ a := downloadAsset(t)
418+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
419+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1})
420+ release, ok := h.acquire(httptest.NewRecorder(), "another-client")
421+ require.True(t, ok)
422+ defer release()
423+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
424+ require.Equal(t, 503, w.Code)
425+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
426+ require.Empty(t, w.Header().Get("Content-Length"))
427+ require.Contains(t, w.Body.String(), "Server busy")
428+ require.NotContains(t, h.clients, "192.0.2.1")
429+}
430+
431+func TestReleaseDownloadIfRange(t *testing.T) {
432+ for _, tc := range []struct {
433+ name, ifRange string
434+ status int
435+ offset int64
436+ length int64
437+ body, cr string
438+ }{
439+ {"matching", `"sha256-abcdef"`, 206, 2, 4, "2345", "bytes 2-5/10"},
440+ {"stale", `"sha256-other"`, 200, 0, 10, "0123456789", ""},
441+ {"date", "Wed, 21 Oct 2026 12:00:00 GMT", 200, 0, 10, "0123456789", ""},
442+ } {
443+ t.Run(tc.name, func(t *testing.T) {
444+ api := mocks.NewGitHubReleaseAPI(t)
445+ dl := mocks.NewReleaseService(t)
446+ a := downloadAsset(t)
447+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
448+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
449+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
450+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
451+ }).Once()
452+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5", "If-Range": tc.ifRange})
453+ require.Equal(t, tc.status, w.Code)
454+ require.Equal(t, tc.body, w.Body.String())
455+ require.Equal(t, tc.cr, w.Header().Get("Content-Range"))
456+ })
457+ }
458+}
459+
460+func TestReleaseAssetValidationResource(t *testing.T) {
461+ api := mocks.NewGitHubReleaseAPI(t)
462+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), mock.Anything).Return(nil, fmt.Errorf("%w: asset name is empty", domain.ErrInvalid)).Once()
463+ w := releaseRequest(t, New(Config{Releases: api}), "PATCH", releaseAssetPath, `{"name":""}`, nil)
464+ require.Equal(t, 422, w.Code)
465+ require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
466+}
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
@@ -0,0 +1,40 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/next",
6+ "id": 4,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "next",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": true,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": null,
36+ "assets": [],
37+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next",
38+ "zipball_url": null,
39+ "body": ""
40+}
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
@@ -0,0 +1,113 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/v1",
6+ "id": 1,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "v1",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": false,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": "2026-10-10T08:00:00Z",
36+ "assets": [
37+ {
38+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3",
39+ "id": 3,
40+ "node_id": "",
41+ "name": "checksums.txt",
42+ "label": "",
43+ "uploader": {
44+ "avatar_url": "",
45+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
46+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
47+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
48+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
49+ "gravatar_id": "",
50+ "html_url": "http://localhost:7341/acme",
51+ "id": 13112345534056,
52+ "login": "acme",
53+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
54+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
55+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
56+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
57+ "site_admin": false,
58+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
59+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
60+ "type": "User",
61+ "url": "http://localhost:7341/-/github/api/users/acme",
62+ "user_view_type": "public"
63+ },
64+ "content_type": "application/octet-stream",
65+ "state": "uploaded",
66+ "size": 3,
67+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
68+ "download_count": 0,
69+ "created_at": "2026-10-10T08:00:00Z",
70+ "updated_at": "2026-10-10T08:00:00Z",
71+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt"
72+ },
73+ {
74+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2",
75+ "id": 2,
76+ "node_id": "",
77+ "name": "ermite.tar.gz",
78+ "label": "",
79+ "uploader": {
80+ "avatar_url": "",
81+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
82+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
83+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
84+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
85+ "gravatar_id": "",
86+ "html_url": "http://localhost:7341/acme",
87+ "id": 13112345534056,
88+ "login": "acme",
89+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
90+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
91+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
92+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
93+ "site_admin": false,
94+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
95+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
96+ "type": "User",
97+ "url": "http://localhost:7341/-/github/api/users/acme",
98+ "user_view_type": "public"
99+ },
100+ "content_type": "application/octet-stream",
101+ "state": "uploaded",
102+ "size": 3,
103+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
104+ "download_count": 0,
105+ "created_at": "2026-10-10T08:00:00Z",
106+ "updated_at": "2026-10-10T08:00:00Z",
107+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz"
108+ }
109+ ],
110+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1",
111+ "zipball_url": null,
112+ "body": ""
113+}
M internal/adapters/kvstore/ci_artifact.go +2 -1
@@ -93,7 +93,7 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
9393 Id: a.ID, Repo: a.Repo.String(), RunId: a.RunID, Attempt: a.Attempt, JobId: a.JobID,
9494 Name: a.Name, MediaType: a.MediaType, Size: a.Size, Digest: a.Digest,
9595 CreatedAt: encodeTime(a.CreatedAt), ExpiresAt: encodeTime(a.ExpiresAt),
96- Charged: a.Charged, ListDigest: a.ListDigest,
96+ Charged: a.Charged, ListDigest: a.ListDigest, Store: string(a.Store),
9797 }
9898 if a.Assignment != (domain.TokenHash{}) {
9999 pb.Assignment = a.Assignment[:]
@@ -123,6 +123,7 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
123123 Name: pb.GetName(), MediaType: pb.GetMediaType(), Size: pb.GetSize(), Digest: pb.GetDigest(),
124124 CreatedAt: decodeTime(pb.GetCreatedAt()), ExpiresAt: decodeTime(pb.GetExpiresAt()),
125125 Charged: pb.GetCharged(), ListDigest: pb.GetListDigest(), Version: item.Version,
126+ Store: domain.CIArtifactStore(pb.GetStore()),
126127 }
127128 copy(a.Assignment[:], pb.GetAssignment())
128129 for _, s := range artifactStates {
M internal/adapters/kvstore/ci_artifact_test.go +5 -0
@@ -40,12 +40,17 @@ func TestCIArtifactsRoundTripAndIndexes(t *testing.T) {
4040 assert.Greater(t, id2, id1, "ids increase")
4141
4242 a := pendingArtifact(n, 7, id1, "dist")
43+ a.Store = domain.CIArtifactStoreBlob
4344 b := pendingArtifact(n, 7, id2, "logs")
4445 require.NoError(t, arts.ApplyArtifacts(ctx, ports.CIArtifactWrite{Repo: n, Put: []domain.CIArtifact{a, b}}))
4546
4647 got, err := arts.GetArtifact(ctx, id1)
4748 require.NoError(t, err)
4849 assert.Equal(t, "dist", got.Name)
50+ assert.Equal(t, domain.CIArtifactStoreBlob, got.Store)
51+ legacy, err := arts.GetArtifact(ctx, id2)
52+ require.NoError(t, err)
53+ assert.Equal(t, domain.CIArtifactStoreLegacy, legacy.Store, "an absent proto field keeps the legacy layout")
4954 assert.Equal(t, a.Assignment, got.Assignment)
5055 assert.Equal(t, domain.CIArtifactPending, got.State)
5156 assert.True(t, got.ExpiresAt.Equal(a.ExpiresAt))
M internal/adapters/kvstore/issues_test.go +16 -8
@@ -271,7 +271,8 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
271271
272272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
273273 // original scope keep their owner rights, gaining mr:review and issue:write
274-// (each only if missing), then mr:force-merge in schema 5; no other token changes.
274+// (each only if missing), then mr:force-merge in schema 5 and release:write in
275+// schema 6; no other token changes.
275276 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
276277 ctx := context.Background()
277278 dir := t.TempDir()
@@ -309,9 +310,14 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
309310 full := get("full")
310311 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
311312 assert.Contains(t, full.Scopes, domain.ScopeMRReview)
312- assert.Len(t, full.Scopes, 12)
313+ assert.Len(t, full.Scopes, 13)
314+ assert.Contains(t, full.Scopes, domain.ScopeReleaseWrite)
313315 full10 := get("full10")
314- assert.Len(t, full10.Scopes, 12)
316+ assert.Len(t, full10.Scopes, 13)
317+ assert.Contains(t, full10.Scopes, domain.ScopeReleaseWrite)
318+ for _, id := range []string{"repos", "prefix", "missing", "reposmr", "small"} {
319+ assert.NotContains(t, get(id).Scopes, domain.ScopeReleaseWrite)
320+ }
315321 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
316322 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
317323 assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
@@ -338,7 +344,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
338344 assert.Equal(t, "full", full.ID)
339345
340346 require.NoError(t, s.Close(ctx))
341- assert.Equal(t, uint32(5), readSchema(t, dir))
347+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
342348
343349 // A second open changes nothing.
344350 s2, err := kvstore.Open(ctx, dir)
@@ -358,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
358364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
359365 _, err := kvstore.Open(ctx, dir)
360366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
361- assert.Equal(t, uint32(5), uint32(kvstore.SchemaVersion))
367+ assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))
362368 }
363369
364370 // A reopen racing the delete of the repository either lands before the delete
@@ -571,7 +577,7 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
571577 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))
572578 var previous []string
573579 for _, sc := range domain.AllScopes() {
574- if sc != domain.ScopeMRForceMerge {
580+ if sc != domain.ScopeMRForceMerge && sc != domain.ScopeReleaseWrite {
575581 previous = append(previous, sc.String())
576582 }
577583 }
@@ -594,9 +600,11 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
594600 if id == "owner" {
595601 assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())
596602 assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))
597- assert.Len(t, tok.Scopes, 12)
603+ assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeReleaseWrite))
604+ assert.Len(t, tok.Scopes, 13)
598605 } else {
599606 assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)
607+ assert.NotContains(t, tok.Scopes, domain.ScopeReleaseWrite)
600608 var got []string
601609 for _, sc := range tok.Scopes {
602610 got = append(got, sc.String())
@@ -605,6 +613,6 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
605613 }
606614 }
607615 require.NoError(t, s.Close(ctx))
608- assert.Equal(t, uint32(5), readSchema(t, dir))
616+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
609617 }
610618 }
A internal/adapters/kvstore/releases.go +408 -0
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ kv "bnema.dev/ermite-kv"
6+ storagepb "bnema.dev/ermite/gen/ermite/storage"
7+ "bnema.dev/ermite/internal/domain"
8+ "bnema.dev/ermite/internal/ports"
9+ "context"
10+ "errors"
11+ "fmt"
12+ "github.com/bnema/zerowrap"
13+ "google.golang.org/protobuf/proto"
14+ "slices"
15+ "strconv"
16+)
17+
18+// Releases implements ports.ReleaseStore over the shared metadata database.
19+type Releases struct{ db *kv.DB }
20+
21+var _ ports.ReleaseStore = (*Releases)(nil)
22+
23+// Releases returns the release metadata store.
24+func (s *Store) Releases() *Releases { return &Releases{db: s.db} }
25+func relKey(repo domain.RepoName, id int64) []byte {
26+ return []byte("rel/" + repo.String() + "/" + fmt.Sprintf(idFormat, id))
27+}
28+func relIDKey(id int64) []byte { return []byte("relid/" + fmt.Sprintf(idFormat, id)) }
29+func relTagKey(repo domain.RepoName, tag string) []byte {
30+ return []byte("reltag/" + repo.String() + "/" + tag)
31+}
32+func relAssetKey(id int64) []byte { return []byte("relasset/" + fmt.Sprintf(idFormat, id)) }
33+func relNamePrefix(repo domain.RepoName, id int64) string {
34+ return "relassets/" + repo.String() + "/" + fmt.Sprintf(idFormat, id) + "/"
35+}
36+func relNameKey(a domain.ReleaseAsset) []byte {
37+ return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)
38+}
39+func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
40+func encodeRelease(r domain.Release) []byte {
41+ return mustMarshal(&storagepb.Release{
42+ Id: r.ID,
43+ Repo: r.Repo.String(),
44+ Tag: r.Tag,
45+ Title: r.Title,
46+ Notes: r.Notes,
47+ Draft: r.Draft,
48+ Prerelease: r.Prerelease,
49+ MakeLatest: r.MakeLatest,
50+ TargetCommitish: r.TargetCommitish,
51+ Author: r.Author,
52+ CreatedAt: encodeTime(r.CreatedAt),
53+ PublishedAt: encodeTime(r.PublishedAt),
54+ UpdatedAt: encodeTime(r.UpdatedAt),
55+ })
56+}
57+func decodeRelease(item kv.Item) (domain.Release, error) {
58+ var pb storagepb.Release
59+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
60+ return domain.Release{}, err
61+ }
62+ repo, err := domain.ParseRepoName(pb.GetRepo())
63+ if err != nil {
64+ return domain.Release{}, err
65+ }
66+ return domain.Release{Version: item.Version,
67+ ID: pb.GetId(),
68+ Repo: repo,
69+ Tag: pb.GetTag(),
70+ Title: pb.GetTitle(),
71+ Notes: pb.GetNotes(),
72+ Draft: pb.GetDraft(),
73+ Prerelease: pb.GetPrerelease(),
74+ MakeLatest: pb.GetMakeLatest(),
75+ TargetCommitish: pb.GetTargetCommitish(),
76+ Author: pb.GetAuthor(),
77+ CreatedAt: decodeTime(pb.GetCreatedAt()),
78+ PublishedAt: decodeTime(pb.GetPublishedAt()),
79+ UpdatedAt: decodeTime(pb.GetUpdatedAt()),
80+ }, nil
81+}
82+func encodeReleaseAsset(r domain.ReleaseAsset) []byte {
83+ return mustMarshal(&storagepb.ReleaseAsset{
84+ Id: r.ID,
85+ ReleaseId: r.ReleaseID,
86+ Repo: r.Repo.String(),
87+ Name: r.Name,
88+ Label: r.Label,
89+ ContentType: r.ContentType,
90+ Size: r.Size,
91+ Digest: r.Digest,
92+ State: string(r.State),
93+ Uploader: r.Uploader,
94+ Charged: r.Charged,
95+ MultipartId: r.MultipartID,
96+ CreatedAt: encodeTime(r.CreatedAt),
97+ UpdatedAt: encodeTime(r.UpdatedAt),
98+ })
99+}
100+func decodeReleaseAsset(item kv.Item) (domain.ReleaseAsset, error) {
101+ var pb storagepb.ReleaseAsset
102+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
103+ return domain.ReleaseAsset{}, err
104+ }
105+ repo, err := domain.ParseRepoName(pb.GetRepo())
106+ if err != nil {
107+ return domain.ReleaseAsset{}, err
108+ }
109+ return domain.ReleaseAsset{Version: item.Version,
110+ ID: pb.GetId(),
111+ ReleaseID: pb.GetReleaseId(),
112+ Repo: repo,
113+ Name: pb.GetName(),
114+ Label: pb.GetLabel(),
115+ ContentType: pb.GetContentType(),
116+ Size: pb.GetSize(),
117+ Digest: pb.GetDigest(),
118+ State: domain.ReleaseAssetState(pb.GetState()),
119+ Uploader: pb.GetUploader(),
120+ Charged: pb.GetCharged(),
121+ MultipartID: pb.GetMultipartId(),
122+ CreatedAt: decodeTime(pb.GetCreatedAt()),
123+ UpdatedAt: decodeTime(pb.GetUpdatedAt()),
124+ }, nil
125+}
126+
127+// NextReleaseID allocates a global monotonically increasing id.
128+func (c *Releases) NextReleaseID(ctx context.Context) (int64, error) {
129+ for range artifactIDTries {
130+ last, item, ok, err := (&CI{db: c.db}).readSeq(ctx, []byte("relseq"))
131+ if err != nil {
132+ return 0, err
133+ }
134+ var b kv.Batch
135+ stageSeq(&b, []byte("relseq"), item, ok, last+1)
136+ _, err = c.db.Apply(ctx, &b)
137+ if errors.Is(err, kv.ErrConflict) {
138+ continue
139+ }
140+ if err != nil {
141+ return 0, err
142+ }
143+ return last + 1, nil
144+ }
145+ return 0, domain.ErrConflict
146+}
147+
148+// ReleaseUsage reads the repository's quota gate.
149+func (c *Releases) ReleaseUsage(ctx context.Context, repo domain.RepoName) (int64, uint64, error) {
150+ item, ok := c.db.Get(ctx, relUseKey(repo))
151+ if !ok {
152+ return 0, 0, nil
153+ }
154+ var pb storagepb.ReleaseUsage
155+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
156+ return 0, 0, err
157+ }
158+ return pb.GetUsed(), item.Version, nil
159+}
160+
161+// GetRelease reads a release in its repository.
162+func (c *Releases) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
163+ item, ok := c.db.Get(ctx, relKey(repo, id))
164+ if !ok {
165+ return domain.Release{}, domain.ErrNotFound
166+ }
167+ return decodeRelease(item)
168+}
169+
170+// ReleaseRepo resolves the global id index.
171+func (c *Releases) ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) {
172+ item, ok := c.db.Get(ctx, relIDKey(id))
173+ if !ok {
174+ return domain.RepoName{}, domain.ErrNotFound
175+ }
176+ return domain.ParseRepoName(string(item.Value))
177+}
178+
179+// GetReleaseByTag resolves a repository tag.
180+func (c *Releases) GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) {
181+ snap := c.db.Snapshot(ctx)
182+ idx, ok := snap.Get(ctx, relTagKey(repo, tag))
183+ if !ok {
184+ return domain.Release{}, domain.ErrNotFound
185+ }
186+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
187+ if err != nil {
188+ return domain.Release{}, err
189+ }
190+ item, ok := snap.Get(ctx, relKey(repo, id))
191+ if !ok {
192+ return domain.Release{}, domain.ErrNotFound
193+ }
194+ return decodeRelease(item)
195+}
196+
197+// ListReleases returns all releases by descending id.
198+func (c *Releases) ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) {
199+ var out []domain.Release
200+ for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("rel/"+repo.String()+"/")) {
201+ r, err := decodeRelease(item)
202+ if err != nil {
203+ return nil, err
204+ }
205+ out = append(out, r)
206+ }
207+ slices.Reverse(out)
208+ return out, nil
209+}
210+
211+// GetAsset reads asset metadata in every state.
212+func (c *Releases) GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) {
213+ item, ok := c.db.Get(ctx, relAssetKey(id))
214+ if !ok {
215+ return domain.ReleaseAsset{}, domain.ErrNotFound
216+ }
217+ return decodeReleaseAsset(item)
218+}
219+
220+// GetAssetByName resolves the unique name index.
221+func (c *Releases) GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) {
222+ snap := c.db.Snapshot(ctx)
223+ idx, ok := snap.Get(ctx, relNameKey(domain.ReleaseAsset{Repo: repo, ReleaseID: releaseID, Name: name}))
224+ if !ok {
225+ return domain.ReleaseAsset{}, domain.ErrNotFound
226+ }
227+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
228+ if err != nil {
229+ return domain.ReleaseAsset{}, err
230+ }
231+ item, ok := snap.Get(ctx, relAssetKey(id))
232+ if !ok {
233+ return domain.ReleaseAsset{}, domain.ErrNotFound
234+ }
235+ return decodeReleaseAsset(item)
236+}
237+
238+// ListAssets returns every state in name order.
239+func (c *Releases) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
240+ snap := c.db.Snapshot(ctx)
241+ var out []domain.ReleaseAsset
242+ for _, idx := range snap.Prefix(ctx, []byte(relNamePrefix(repo, releaseID))) {
243+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
244+ if err != nil {
245+ return nil, err
246+ }
247+ item, ok := snap.Get(ctx, relAssetKey(id))
248+ if !ok {
249+ return nil, domain.ErrNotFound
250+ }
251+ a, err := decodeReleaseAsset(item)
252+ if err != nil {
253+ return nil, err
254+ }
255+ out = append(out, a)
256+ }
257+ return out, nil
258+}
259+
260+// AllAssets returns every asset for garbage collection.
261+func (c *Releases) AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) {
262+ var out []domain.ReleaseAsset
263+ for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("relasset/")) {
264+ a, err := decodeReleaseAsset(item)
265+ if err != nil {
266+ return nil, err
267+ }
268+ out = append(out, a)
269+ }
270+ return out, nil
271+}
272+
273+// ApplyReleases atomically changes metadata and its quota gate.
274+//
275+//hexcheck:ignore suspicious-business-logic-in-adapter enforces storage version preconditions and unique secondary indexes, not release policy
276+func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) error {
277+ log := zerowrap.FromCtx(ctx)
278+ var b kv.Batch
279+ if err := guardRepoKey(ctx, c.db, &b, w.Repo); err != nil {
280+ return err
281+ }
282+ if w.UsageVersion == 0 {
283+ b.RequireAbsent(relUseKey(w.Repo))
284+ } else {
285+ b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
286+ }
287+ b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
288+ snap := c.db.Snapshot(ctx)
289+ for _, r := range w.DeleteReleases {
290+ if r.Repo != w.Repo || r.Version == 0 {
291+ return domain.ErrInvalid
292+ }
293+ b.RequireVersion(relKey(r.Repo, r.ID), r.Version)
294+ b.Delete(relKey(r.Repo, r.ID))
295+ b.Delete(relTagKey(r.Repo, r.Tag))
296+ b.Delete(relIDKey(r.ID))
297+ }
298+ for _, r := range w.DeleteAssets {
299+ if r.Repo != w.Repo || r.Version == 0 {
300+ return domain.ErrInvalid
301+ }
302+ b.RequireVersion(relAssetKey(r.ID), r.Version)
303+ b.Delete(relAssetKey(r.ID))
304+ b.Delete(relNameKey(r))
305+ }
306+ seenReleases := map[string]int64{}
307+ for _, r := range w.PutReleases {
308+ if r.Repo != w.Repo || r.ID < 1 {
309+ return domain.ErrInvalid
310+ }
311+ idxKey := relTagKey(r.Repo, r.Tag)
312+ if id, ok := seenReleases[string(idxKey)]; ok && id != r.ID {
313+ return domain.ErrAlreadyExists
314+ }
315+ seenReleases[string(idxKey)] = r.ID
316+ if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) {
317+ removed := false
318+ for _, d := range w.DeleteReleases {
319+ if string(relTagKey(d.Repo, d.Tag)) == string(idxKey) {
320+ removed = true
321+ }
322+ }
323+ if !removed {
324+ return domain.ErrAlreadyExists
325+ }
326+ }
327+ if old, ok := snap.Get(ctx, relKey(r.Repo, r.ID)); ok {
328+ previous, err := decodeRelease(old)
329+ if err != nil {
330+ return err
331+ }
332+ oldKey := relTagKey(previous.Repo, previous.Tag)
333+ if string(oldKey) != string(idxKey) {
334+ b.Delete(oldKey)
335+ }
336+ }
337+ stage(&b, relKey(r.Repo, r.ID), encodeRelease(r), r.Version)
338+ b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID)))
339+ if r.Version == 0 {
340+ b.RequireAbsent(relIDKey(r.ID))
341+ }
342+ b.Put(relIDKey(r.ID), []byte(r.Repo.String()))
343+ }
344+ seenAssets := map[string]int64{}
345+ for _, r := range w.PutAssets {
346+ if r.Repo != w.Repo || r.ID < 1 {
347+ return domain.ErrInvalid
348+ }
349+ idxKey := relNameKey(r)
350+ if id, ok := seenAssets[string(idxKey)]; ok && id != r.ID {
351+ return domain.ErrAlreadyExists
352+ }
353+ seenAssets[string(idxKey)] = r.ID
354+ if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) {
355+ removed := false
356+ for _, d := range w.DeleteAssets {
357+ if string(relNameKey(d)) == string(idxKey) {
358+ removed = true
359+ }
360+ }
361+ if !removed {
362+ return domain.ErrAlreadyExists
363+ }
364+ }
365+ if old, ok := snap.Get(ctx, relAssetKey(r.ID)); ok {
366+ previous, err := decodeReleaseAsset(old)
367+ if err != nil {
368+ return err
369+ }
370+ oldKey := relNameKey(previous)
371+ if string(oldKey) != string(idxKey) {
372+ b.Delete(oldKey)
373+ }
374+ }
375+ stage(&b, relAssetKey(r.ID), encodeReleaseAsset(r), r.Version)
376+ b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID)))
377+ }
378+ _, err := c.db.Apply(ctx, &b)
379+ if errors.Is(err, kv.ErrConflict) {
380+ return domain.ErrConflict
381+ }
382+ if err != nil {
383+ return fmt.Errorf("write releases: %w", err)
384+ }
385+ log.Debug().Str("repo", w.Repo.String()).Msg("release metadata updated")
386+ return nil
387+}
388+
389+// releaseKeys collects all repository-owned release keys, including global indexes.
390+func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
391+ keys := [][]byte{relUseKey(repo)}
392+ for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {
393+ for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {
394+ keys = append(keys, slices.Clone(key))
395+ if prefix == "rel/" {
396+ if r, err := decodeRelease(item); err == nil {
397+ keys = append(keys, relIDKey(r.ID))
398+ }
399+ }
400+ }
401+ }
402+ for key, item := range snap.Prefix(ctx, []byte("relasset/")) {
403+ if a, err := decodeReleaseAsset(item); err == nil && a.Repo == repo {
404+ keys = append(keys, slices.Clone(key))
405+ }
406+ }
407+ return keys
408+}
A internal/adapters/kvstore/releases_test.go +131 -0
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "context"
6+ "sync"
7+ "testing"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+func TestReleaseStorage(t *testing.T) {
17+ ctx := context.Background()
18+ dir := t.TempDir()
19+ s := openStore(t, dir)
20+ repo := name(t, "release-test")
21+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
22+ st := s.Releases()
23+ id, err := st.NextReleaseID(ctx)
24+ require.NoError(t, err)
25+ r := domain.Release{ID: id, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", MakeLatest: true, TargetCommitish: "abc", Author: "owner", CreatedAt: created, PublishedAt: created, UpdatedAt: created}
26+ assetID, err := st.NextReleaseID(ctx)
27+ require.NoError(t, err)
28+ a := domain.ReleaseAsset{ID: assetID, ReleaseID: id, Repo: repo, Name: "binary", Label: "label", ContentType: "application/octet-stream", Size: 3, Digest: "sha256:abc", State: domain.ReleaseAssetUploading, Uploader: "owner", Charged: 3, MultipartID: "multipart", CreatedAt: created, UpdatedAt: created}
29+ w := ports.ReleaseWrite{Repo: repo, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}
30+ require.NoError(t, st.ApplyReleases(ctx, w))
31+ got, err := st.GetReleaseByTag(ctx, repo, "v1")
32+ require.NoError(t, err)
33+ r.Version = got.Version
34+ require.Equal(t, r, got)
35+ ar, err := st.GetAssetByName(ctx, repo, id, "binary")
36+ require.NoError(t, err)
37+ a.Version = ar.Version
38+ require.Equal(t, a, ar)
39+ rr, err := st.ReleaseRepo(ctx, id)
40+ require.NoError(t, err)
41+ require.Equal(t, repo, rr)
42+ rs, err := st.ListReleases(ctx, repo)
43+ require.NoError(t, err)
44+ require.Equal(t, []domain.Release{r}, rs)
45+ as, err := st.ListAssets(ctx, repo, id)
46+ require.NoError(t, err)
47+ require.Equal(t, []domain.ReleaseAsset{a}, as)
48+ all, err := st.AllAssets(ctx)
49+ require.NoError(t, err)
50+ require.Equal(t, as, all)
51+ require.ErrorIs(t, st.ApplyReleases(ctx, w), domain.ErrConflict)
52+ used, version, err := st.ReleaseUsage(ctx, repo)
53+ require.NoError(t, err)
54+ require.EqualValues(t, 3, used)
55+ duplicate := r
56+ duplicate.ID += 10
57+ duplicate.Version = 0
58+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{duplicate}}), domain.ErrAlreadyExists)
59+ da := a
60+ da.ID += 10
61+ da.Version = 0
62+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutAssets: []domain.ReleaseAsset{da}}), domain.ErrAlreadyExists)
63+ stale := r
64+ stale.Version++
65+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{stale}}), domain.ErrConflict)
66+ staleAsset := a
67+ staleAsset.Version++
68+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteAssets: []domain.ReleaseAsset{staleAsset}}), domain.ErrConflict)
69+ r.Tag = "v2"
70+ a.Name = "renamed"
71+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}))
72+ _, err = st.GetReleaseByTag(ctx, repo, "v1")
73+ require.ErrorIs(t, err, domain.ErrNotFound)
74+ _, err = st.GetAssetByName(ctx, repo, id, "binary")
75+ require.ErrorIs(t, err, domain.ErrNotFound)
76+ renamedRelease, err := st.GetReleaseByTag(ctx, repo, "v2")
77+ require.NoError(t, err)
78+ renamedAsset, err := st.GetAssetByName(ctx, repo, id, "renamed")
79+ require.NoError(t, err)
80+ _, version, err = st.ReleaseUsage(ctx, repo)
81+ require.NoError(t, err)
82+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteReleases: []domain.Release{renamedRelease}, DeleteAssets: []domain.ReleaseAsset{renamedAsset}}))
83+ _, err = st.GetRelease(ctx, repo, id)
84+ require.ErrorIs(t, err, domain.ErrNotFound)
85+ _, err = st.GetAsset(ctx, a.ID)
86+ require.ErrorIs(t, err, domain.ErrNotFound)
87+ _, version, err = st.ReleaseUsage(ctx, repo)
88+ require.NoError(t, err)
89+ r.Version, a.Version = 0, 0
90+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}))
91+ require.NoError(t, s.Delete(ctx, repo))
92+ _, err = st.GetRelease(ctx, repo, id)
93+ require.ErrorIs(t, err, domain.ErrNotFound)
94+ _, err = st.ReleaseRepo(ctx, id)
95+ require.ErrorIs(t, err, domain.ErrNotFound)
96+ _, err = st.GetAsset(ctx, a.ID)
97+ require.ErrorIs(t, err, domain.ErrNotFound)
98+ all, err = st.AllAssets(ctx)
99+ require.NoError(t, err)
100+ require.Empty(t, all)
101+ used, version, err = st.ReleaseUsage(ctx, repo)
102+ require.NoError(t, err)
103+ require.Zero(t, used)
104+ require.Zero(t, version)
105+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo}), domain.ErrNotFound)
106+ require.NoError(t, s.Close(ctx))
107+ db, err := kv.Open(ctx, dir, kv.Options{})
108+ require.NoError(t, err)
109+ defer func() { _ = db.Close(ctx) }()
110+ for _, prefix := range []string{"rel/", "reltag/", "relid/", "relasset/", "relassets/", "reluse/"} {
111+ for key := range db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
112+ t.Errorf("repository deletion left release key %q", key)
113+ }
114+ }
115+}
116+func TestConcurrentReleaseIDs(t *testing.T) {
117+ st := openStore(t, t.TempDir()).Releases()
118+ var wg sync.WaitGroup
119+ ids := make(chan int64, 32)
120+ for range 32 {
121+ wg.Go(func() { id, err := st.NextReleaseID(context.Background()); require.NoError(t, err); ids <- id })
122+ }
123+ wg.Wait()
124+ close(ids)
125+ seen := map[int64]bool{}
126+ for id := range ids {
127+ require.False(t, seen[id])
128+ seen[id] = true
129+ }
130+ require.Len(t, seen, 32)
131+}
M internal/adapters/kvstore/repos.go +1 -0
@@ -163,6 +163,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
163163 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
164164 keys = append(keys, pendingKeys(ctx, snap, name)...)
165165 keys = append(keys, ciKeys(ctx, snap, name)...)
166+ keys = append(keys, releaseKeys(ctx, snap, name)...)
166167 keys = append(keys, settingsKeys(ctx, snap, name)...)
167168 keys = append(keys, mirrorKeys(ctx, snap, name)...)
168169 for len(keys) > deleteChunk {
M internal/adapters/kvstore/store.go +25 -4
@@ -40,8 +40,10 @@ import (
4040 // if missing) to the stored tokens that held all nine original scopes with no
4141 // repository list and no branch prefix, so that they keep the owner rights (see
4242 // upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens
43-// holding all eleven previous scopes, preserving their owner rights.
44-const SchemaVersion = 5
43+// holding all eleven previous scopes, preserving their owner rights. Version 6
44+// adds releases and release:write; older builds cannot clean up their metadata
45+// or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).
46+const SchemaVersion = 6
4547
4648 const schemaKey = "meta/schema"
4749
@@ -110,7 +112,7 @@ func (s *Store) Check(ctx context.Context) error {
110112 func (s *Store) checkSchema(ctx context.Context) error {
111113 log := zerowrap.FromCtx(ctx)
112114 key := []byte(schemaKey)
113- for range 4 {
115+ for range 5 {
114116 item, ok := s.db.Get(ctx, key)
115117 if !ok {
116118 var b kv.Batch
@@ -142,8 +144,11 @@ func (s *Store) checkSchema(ctx context.Context) error {
142144 // The tokens are upgraded in the same batch, so that a crash
143145 // leaves the old version and the upgrade runs again.
144146 target := uint32(SchemaVersion)
145- if sv.GetVersion() < 4 {
147+ switch {
148+ case sv.GetVersion() < 4:
146149 target = 4
150+ case sv.GetVersion() == 4:
151+ target = 5
147152 }
148153 var b kv.Batch
149154 b.RequireVersion(key, item.Version)
@@ -157,6 +162,11 @@ func (s *Store) checkSchema(ctx context.Context) error {
157162 return err
158163 }
159164 }
165+ if sv.GetVersion() == 5 {
166+ if err := s.upgradeReleaseTokens(ctx, &b); err != nil {
167+ return err
168+ }
169+ }
160170 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target}))
161171 _, err := s.db.Apply(ctx, &b)
162172 if errors.Is(err, kv.ErrConflict) {
@@ -214,6 +224,17 @@ func (s *Store) upgradeForceMergeTokens(ctx context.Context, b *kv.Batch) error
214224 return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge})
215225 }
216226
227+// preReleaseScopes are the twelve scopes of schema 5, before release:write.
228+var preReleaseScopes = append(slices.Clone(preForceMergeScopes), domain.ScopeMRForceMerge)
229+
230+// upgradeReleaseTokens adds to b the schema 6 upgrade of the tokens: a token
231+// that holds all of preReleaseScopes, limited to no repository and no branch
232+// prefix, had the owner rights, which AllScopes also needs release:write for,
233+// so it gets it. Restricted or partial tokens are left as they are.
234+func (s *Store) upgradeReleaseTokens(ctx context.Context, b *kv.Batch) error {
235+ return s.upgradeTokenScopes(ctx, b, preReleaseScopes, []domain.Scope{domain.ScopeReleaseWrite})
236+}
237+
217238 func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error {
218239 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
219240 pb := &storagepb.Token{}
M internal/adapters/kvstore/store_test.go +57 -0
@@ -363,3 +363,60 @@ func TestRepoWithoutTopicsReadsNil(t *testing.T) {
363363 require.NoError(t, err)
364364 assert.Equal(t, []string{"a", "b"}, got.Topics)
365365 }
366+
367+func TestSchema6TokenUpgrade(t *testing.T) {
368+ ctx := context.Background()
369+ for _, from := range []uint32{3, 4, 5} {
370+ t.Run(fmt.Sprint(from), func(t *testing.T) {
371+ dir := t.TempDir()
372+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: from}))
373+ all := domain.AllScopes()
374+ scopes := make([]string, 0, len(all)-1)
375+ for _, sc := range all {
376+ if sc != domain.ScopeReleaseWrite {
377+ scopes = append(scopes, sc.String())
378+ }
379+ }
380+ for _, tc := range []struct {
381+ id string
382+ scopes []string
383+ repos, prefixes []string
384+ upgraded bool
385+ }{
386+ {id: "full", scopes: scopes, upgraded: true},
387+ {id: "partial", scopes: []string{"repo:read"}},
388+ {id: "missing-review", scopes: func() []string {
389+ var out []string
390+ for _, s := range scopes {
391+ if s != "mr:review" {
392+ out = append(out, s)
393+ }
394+ }
395+ return out
396+ }(), upgraded: from < 4},
397+ {id: "repo", scopes: scopes, repos: []string{"a"}},
398+ {id: "branch", scopes: scopes, prefixes: []string{"bot/"}},
399+ } {
400+ putRaw(t, dir, "token/"+tc.id, mustProto(t, &storagepb.Token{Id: tc.id, Scopes: tc.scopes, Repos: tc.repos, BranchPrefixes: tc.prefixes}))
401+ }
402+ s, err := kvstore.Open(ctx, dir)
403+ require.NoError(t, err)
404+ require.NoError(t, s.Close(ctx))
405+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
406+ db, err := kv.Open(ctx, dir, kv.Options{})
407+ require.NoError(t, err)
408+ defer func() { _ = db.Close(ctx) }()
409+ for _, id := range []string{"full", "partial", "missing-review", "repo", "branch"} {
410+ item, ok := db.Get(ctx, []byte("token/"+id))
411+ require.True(t, ok)
412+ var pb storagepb.Token
413+ require.NoError(t, proto.Unmarshal(item.Value, &pb))
414+ if id == "full" || (id == "missing-review" && from < 4) {
415+ assert.Contains(t, pb.Scopes, "release:write")
416+ } else {
417+ assert.NotContains(t, pb.Scopes, "release:write")
418+ }
419+ }
420+ })
421+ }
422+}
M internal/adapters/rpc/api.go +9 -0
@@ -59,6 +59,8 @@ type Config struct {
5959 SigningKey ports.SigningKeyService
6060 // Issues serves IssueService.
6161 Issues ports.IssueService
62+ // Releases serves ReleaseService when set; leave unset rather than a typed nil.
63+ Releases ports.ReleaseService
6264 // IssueModeration serves the moderation calls of IssueService.
6365 IssueModeration ports.IssueModerationService
6466 Version string
@@ -142,6 +144,9 @@ func New(cfg Config) *API {
142144 if artifacts {
143145 features = append(features, FeatureCIArtifacts)
144146 }
147+ if cfg.Releases != nil {
148+ features = append(features, FeatureReleases)
149+ }
145150 if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
146151 features = append(features, FeatureCIOIDC)
147152 }
@@ -162,6 +167,10 @@ func New(cfg Config) *API {
162167 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
163168 mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
164169 }
170+ if cfg.Releases != nil {
171+ releaseOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
172+ mux.Handle(ermiteconnect.NewReleaseServiceHandler(&releaseService{cfg.Releases}, releaseOpts...))
173+ }
165174 if cfg.Runner != nil {
166175 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
167176 runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter}
M internal/adapters/rpc/convert.go +28 -3
@@ -137,6 +137,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
137137 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
138138 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
139139 domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
140+ domain.ScopeReleaseWrite: pb.Scope_SCOPE_RELEASE_WRITE,
140141 }
141142
142143 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -537,9 +538,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
537538 out.Time = timestamp(ev.At)
538539 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
539540 default:
540- // Web-only events, such as domain.IssueReferenced and
541- // domain.IssuePendingRemoved: the API has no messages for them.
542- return nil
541+ // Release lifecycle conversions live alongside the event service.
542+ // Web-only events have no representation in the API.
543+ return releaseEventToPB(we)
543544 }
544545 return out
545546 }
@@ -566,3 +567,27 @@ func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
566567 }
567568 return out
568569 }
570+
571+func releaseToPB(r domain.Release) *pb.Release {
572+ return &pb.Release{
573+ Id: r.ID, Repo: r.Repo.String(), Tag: r.Tag, Title: r.Title, Notes: r.Notes,
574+ Draft: r.Draft, Prerelease: r.Prerelease, TargetCommitish: r.TargetCommitish, Author: r.Author,
575+ CreatedAt: timestamp(r.CreatedAt), PublishedAt: timestamp(r.PublishedAt), UpdatedAt: timestamp(r.UpdatedAt),
576+ }
577+}
578+
579+func assetToPB(a domain.ReleaseAsset) *pb.ReleaseAsset {
580+ return &pb.ReleaseAsset{
581+ Id: a.ID, ReleaseId: a.ReleaseID, Name: a.Name, Label: a.Label, ContentType: a.ContentType,
582+ Size: a.Size, Digest: a.Digest, Uploader: a.Uploader,
583+ CreatedAt: timestamp(a.CreatedAt), UpdatedAt: timestamp(a.UpdatedAt),
584+ }
585+}
586+
587+func assetsToPB(assets []domain.ReleaseAsset) []*pb.ReleaseAsset {
588+ out := make([]*pb.ReleaseAsset, 0, len(assets))
589+ for _, a := range assets {
590+ out = append(out, assetToPB(a))
591+ }
592+ return out
593+}
M internal/adapters/rpc/errors.go +2 -0
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
5555 return connect.CodeAborted, true
5656 case errors.Is(err, domain.ErrResourceExhausted):
5757 return connect.CodeResourceExhausted, true
58+ case errors.Is(err, domain.ErrOutOfRange):
59+ return connect.CodeOutOfRange, true
5860 case errors.Is(err, domain.ErrUnavailable):
5961 return connect.CodeUnavailable, true
6062 }
M internal/adapters/rpc/event.go +26 -0
@@ -8,6 +8,7 @@ import (
88 "connectrpc.com/connect"
99
1010 pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
1112 "bnema.dev/ermite/internal/ports"
1213 )
1314
@@ -37,3 +38,28 @@ func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchR
3738 })
3839 return connectError(ctx, err)
3940 }
41+
42+func releaseEventToPB(we ports.WatchEvent) *pb.Event {
43+ out := &pb.Event{Id: we.ID}
44+ switch ev := we.Event.(type) {
45+ case domain.ReleasePublished:
46+ r := ev.Release
47+ out.Time = timestamp(ev.At)
48+ out.Kind = &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
49+ case domain.ReleaseUpdated:
50+ r := ev.Release
51+ out.Time = timestamp(ev.At)
52+ out.Kind = &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
53+ case domain.ReleaseDeleted:
54+ r := ev.Release
55+ out.Time = timestamp(ev.At)
56+ out.Kind = &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag}}
57+ case domain.ReleaseUnpublished:
58+ r := ev.Release
59+ out.Time = timestamp(ev.At)
60+ out.Kind = &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
61+ default:
62+ return nil
63+ }
64+ return out
65+}
A internal/adapters/rpc/release.go +358 -0
@@ -0,0 +1,358 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// FeatureReleases is listed by GetVersion when ReleaseService is served.
20+const FeatureReleases = "releases"
21+
22+// releaseService implements ermiteconnect.ReleaseServiceHandler.
23+type releaseService struct{ rel ports.ReleaseService }
24+
25+var _ ermiteconnect.ReleaseServiceHandler = (*releaseService)(nil)
26+
27+// releaseMessage fills the computed latest flag and uploaded assets using the
28+// same caller's read permissions. No latest release is a normal repository state.
29+func (s *releaseService) releaseMessage(ctx context.Context, repo domain.RepoName, r domain.Release) (*pb.Release, error) {
30+ latest, err := s.latestID(ctx, repo)
31+ if err != nil {
32+ return nil, err
33+ }
34+ return s.releaseRow(ctx, repo, r, latest)
35+}
36+
37+// latestID is the id of the latest release, or 0 when the repository has none.
38+func (s *releaseService) latestID(ctx context.Context, repo domain.RepoName) (int64, error) {
39+ latest, err := s.rel.GetRelease(ctx, caller(ctx), repo, ports.ReleaseRef{Latest: true})
40+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
41+ return 0, err
42+ }
43+ return latest.ID, nil
44+}
45+
46+// releaseRow converts one release given the id of the latest one, so a list
47+// resolves the latest release once.
48+func (s *releaseService) releaseRow(ctx context.Context, repo domain.RepoName, r domain.Release, latest int64) (*pb.Release, error) {
49+ assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, r.ID)
50+ if err != nil {
51+ return nil, err
52+ }
53+ out := releaseToPB(r)
54+ out.Latest = latest != 0 && latest == r.ID
55+ out.Assets = assetsToPB(assets)
56+ return out, nil
57+}
58+
59+func (s *releaseService) CreateRelease(ctx context.Context, req *connect.Request[pb.CreateReleaseRequest]) (*connect.Response[pb.CreateReleaseResponse], error) {
60+ m := req.Msg
61+ repo, err := repoOf(m.GetRepo())
62+ if err != nil {
63+ return nil, connectError(ctx, err)
64+ }
65+ r, err := s.rel.CreateRelease(ctx, caller(ctx), repo, ports.CreateReleaseParams{Tag: m.GetTag(), TargetCommitish: m.GetTargetCommitish(), Title: m.GetTitle(), Notes: m.GetNotes(), Draft: m.GetDraft(), Prerelease: m.GetPrerelease(), MakeLatest: m.GetMakeLatest()})
66+ if err != nil {
67+ return nil, connectError(ctx, err)
68+ }
69+ out, err := s.releaseMessage(ctx, repo, r)
70+ if err != nil {
71+ return nil, connectError(ctx, err)
72+ }
73+ return connect.NewResponse(&pb.CreateReleaseResponse{Release: out}), nil
74+}
75+
76+func releaseRef(m *pb.GetReleaseRequest) (ports.ReleaseRef, error) {
77+ switch v := m.GetSelector().(type) {
78+ case *pb.GetReleaseRequest_Id:
79+ if v.Id > 0 {
80+ return ports.ReleaseRef{ID: v.Id}, nil
81+ }
82+ case *pb.GetReleaseRequest_Tag:
83+ if v.Tag != "" {
84+ return ports.ReleaseRef{Tag: v.Tag}, nil
85+ }
86+ case *pb.GetReleaseRequest_Latest:
87+ if v.Latest {
88+ return ports.ReleaseRef{Latest: true}, nil
89+ }
90+ }
91+ return ports.ReleaseRef{}, fmt.Errorf("%w: select an id, tag or latest=true", domain.ErrInvalid)
92+}
93+
94+func (s *releaseService) GetRelease(ctx context.Context, req *connect.Request[pb.GetReleaseRequest]) (*connect.Response[pb.GetReleaseResponse], error) {
95+ repo, err := repoOf(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ ref, err := releaseRef(req.Msg)
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ r, err := s.rel.GetRelease(ctx, caller(ctx), repo, ref)
104+ if err != nil {
105+ return nil, connectError(ctx, err)
106+ }
107+ out, err := s.releaseMessage(ctx, repo, r)
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ return connect.NewResponse(&pb.GetReleaseResponse{Release: out}), nil
112+}
113+
114+func (s *releaseService) ListReleases(ctx context.Context, req *connect.Request[pb.ListReleasesRequest]) (*connect.Response[pb.ListReleasesResponse], error) {
115+ repo, err := repoOf(req.Msg.GetRepo())
116+ if err != nil {
117+ return nil, connectError(ctx, err)
118+ }
119+ page, err := s.rel.ListReleases(ctx, caller(ctx), repo, int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
120+ if err != nil {
121+ return nil, connectError(ctx, err)
122+ }
123+ out := &pb.ListReleasesResponse{Releases: make([]*pb.Release, 0, len(page.Releases)), NextPageToken: page.NextPageToken}
124+ latest, err := s.latestID(ctx, repo)
125+ if err != nil {
126+ return nil, connectError(ctx, err)
127+ }
128+ for _, r := range page.Releases {
129+ m, err := s.releaseRow(ctx, repo, r, latest)
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ out.Releases = append(out.Releases, m)
134+ }
135+ return connect.NewResponse(out), nil
136+}
137+
138+func releaseUpdateParams(m *pb.UpdateReleaseRequest) (ports.UpdateReleaseParams, error) {
139+ var p ports.UpdateReleaseParams
140+ if len(m.GetUpdateMask().GetPaths()) == 0 {
141+ return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
142+ }
143+ for _, path := range m.GetUpdateMask().GetPaths() {
144+ switch path {
145+ case "tag":
146+ p.Tag = new(m.GetTag())
147+ case "target_commitish":
148+ p.TargetCommitish = new(m.GetTargetCommitish())
149+ case "title":
150+ p.Title = new(m.GetTitle())
151+ case "notes":
152+ p.Notes = new(m.GetNotes())
153+ case "draft":
154+ p.Draft = new(m.GetDraft())
155+ case "prerelease":
156+ p.Prerelease = new(m.GetPrerelease())
157+ case "make_latest":
158+ p.MakeLatest = new(m.GetMakeLatest())
159+ default:
160+ return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path)
161+ }
162+ }
163+ return p, nil
164+}
165+
166+func (s *releaseService) UpdateRelease(ctx context.Context, req *connect.Request[pb.UpdateReleaseRequest]) (*connect.Response[pb.UpdateReleaseResponse], error) {
167+ repo, err := repoOf(req.Msg.GetRepo())
168+ if err != nil {
169+ return nil, connectError(ctx, err)
170+ }
171+ p, err := releaseUpdateParams(req.Msg)
172+ if err != nil {
173+ return nil, connectError(ctx, err)
174+ }
175+ r, err := s.rel.UpdateRelease(ctx, caller(ctx), repo, req.Msg.GetId(), p)
176+ if err != nil {
177+ return nil, connectError(ctx, err)
178+ }
179+ out, err := s.releaseMessage(ctx, repo, r)
180+ if err != nil {
181+ return nil, connectError(ctx, err)
182+ }
183+ return connect.NewResponse(&pb.UpdateReleaseResponse{Release: out}), nil
184+}
185+
186+func (s *releaseService) DeleteRelease(ctx context.Context, req *connect.Request[pb.DeleteReleaseRequest]) (*connect.Response[pb.DeleteReleaseResponse], error) {
187+ repo, err := repoOf(req.Msg.GetRepo())
188+ if err != nil {
189+ return nil, connectError(ctx, err)
190+ }
191+ if err := s.rel.DeleteRelease(ctx, caller(ctx), repo, req.Msg.GetId()); err != nil {
192+ return nil, connectError(ctx, err)
193+ }
194+ return connect.NewResponse(&pb.DeleteReleaseResponse{}), nil
195+}
196+
197+// chunkReader retains at most one message; metadata belongs to the first only.
198+type chunkReader struct {
199+ stream *connect.ClientStream[pb.UploadReleaseAssetRequest]
200+ buf []byte
201+}
202+
203+func (r *chunkReader) Read(p []byte) (int, error) {
204+ if len(p) == 0 {
205+ return 0, nil
206+ }
207+ for len(r.buf) == 0 {
208+ if !r.stream.Receive() {
209+ if err := r.stream.Err(); err != nil {
210+ return 0, err
211+ }
212+ return 0, io.EOF
213+ }
214+ m := r.stream.Msg()
215+ if m.GetRepo() != "" || m.GetReleaseId() != 0 || m.GetName() != "" || m.GetLabel() != "" || m.GetContentType() != "" || m.GetSize() != 0 || m.GetReplace() {
216+ return 0, fmt.Errorf("%w: upload metadata is in the first message only", domain.ErrInvalid)
217+ }
218+ if err := checkBlockData(m.GetData()); err != nil {
219+ return 0, err
220+ }
221+ r.buf = m.GetData()
222+ }
223+ n := copy(p, r.buf)
224+ r.buf = r.buf[n:]
225+ return n, nil
226+}
227+
228+func (s *releaseService) UploadReleaseAsset(ctx context.Context, stream *connect.ClientStream[pb.UploadReleaseAssetRequest]) (*connect.Response[pb.UploadReleaseAssetResponse], error) {
229+ if !stream.Receive() {
230+ err := stream.Err()
231+ if err == nil {
232+ err = fmt.Errorf("%w: the stream has no message", domain.ErrInvalid)
233+ }
234+ return nil, connectError(ctx, err)
235+ }
236+ m := stream.Msg()
237+ repo, err := repoOf(m.GetRepo())
238+ if err != nil {
239+ return nil, connectError(ctx, err)
240+ }
241+ if m.GetSize() < 0 {
242+ return nil, connectError(ctx, fmt.Errorf("%w: size must be nonnegative", domain.ErrInvalid))
243+ }
244+ if err := checkBlockData(m.GetData()); err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ a, err := s.rel.UploadAsset(ctx, caller(ctx), repo, ports.UploadAssetParams{ReleaseID: m.GetReleaseId(), Name: m.GetName(), Label: m.GetLabel(), ContentType: m.GetContentType(), Size: m.GetSize(), Replace: m.GetReplace()}, &chunkReader{stream: stream, buf: m.GetData()})
248+ if err != nil {
249+ return nil, connectError(ctx, err)
250+ }
251+ return connect.NewResponse(&pb.UploadReleaseAssetResponse{Asset: assetToPB(a)}), nil
252+}
253+
254+func assetUpdateParams(m *pb.UpdateReleaseAssetRequest) (ports.UpdateAssetParams, error) {
255+ var p ports.UpdateAssetParams
256+ if len(m.GetUpdateMask().GetPaths()) == 0 {
257+ return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
258+ }
259+ for _, path := range m.GetUpdateMask().GetPaths() {
260+ switch path {
261+ case "name":
262+ p.Name = new(m.GetName())
263+ case "label":
264+ p.Label = new(m.GetLabel())
265+ default:
266+ return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path)
267+ }
268+ }
269+ return p, nil
270+}
271+
272+func (s *releaseService) UpdateReleaseAsset(ctx context.Context, req *connect.Request[pb.UpdateReleaseAssetRequest]) (*connect.Response[pb.UpdateReleaseAssetResponse], error) {
273+ repo, err := repoOf(req.Msg.GetRepo())
274+ if err != nil {
275+ return nil, connectError(ctx, err)
276+ }
277+ p, err := assetUpdateParams(req.Msg)
278+ if err != nil {
279+ return nil, connectError(ctx, err)
280+ }
281+ a, err := s.rel.UpdateAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId(), p)
282+ if err != nil {
283+ return nil, connectError(ctx, err)
284+ }
285+ return connect.NewResponse(&pb.UpdateReleaseAssetResponse{Asset: assetToPB(a)}), nil
286+}
287+
288+func (s *releaseService) DeleteReleaseAsset(ctx context.Context, req *connect.Request[pb.DeleteReleaseAssetRequest]) (*connect.Response[pb.DeleteReleaseAssetResponse], error) {
289+ repo, err := repoOf(req.Msg.GetRepo())
290+ if err != nil {
291+ return nil, connectError(ctx, err)
292+ }
293+ if err := s.rel.DeleteAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId()); err != nil {
294+ return nil, connectError(ctx, err)
295+ }
296+ return connect.NewResponse(&pb.DeleteReleaseAssetResponse{}), nil
297+}
298+
299+func assetRef(m *pb.DownloadReleaseAssetRequest) (ports.AssetRef, error) {
300+ switch v := m.GetSelector().(type) {
301+ case *pb.DownloadReleaseAssetRequest_AssetId:
302+ if v.AssetId > 0 {
303+ return ports.AssetRef{ID: v.AssetId}, nil
304+ }
305+ case *pb.DownloadReleaseAssetRequest_Tag:
306+ if v.Tag.GetTag() != "" && v.Tag.GetName() != "" {
307+ return ports.AssetRef{Tag: v.Tag.GetTag(), Name: v.Tag.GetName()}, nil
308+ }
309+ case *pb.DownloadReleaseAssetRequest_Latest:
310+ if v.Latest.GetLatest() && v.Latest.GetName() != "" {
311+ return ports.AssetRef{Latest: true, Name: v.Latest.GetName()}, nil
312+ }
313+ }
314+ return ports.AssetRef{}, fmt.Errorf("%w: select an asset id, tag/name or latest=true/name", domain.ErrInvalid)
315+}
316+
317+func (s *releaseService) DownloadReleaseAsset(ctx context.Context, req *connect.Request[pb.DownloadReleaseAssetRequest], stream *connect.ServerStream[pb.DownloadReleaseAssetResponse]) error {
318+ repo, err := repoOf(req.Msg.GetRepo())
319+ if err != nil {
320+ return connectError(ctx, err)
321+ }
322+ ref, err := assetRef(req.Msg)
323+ if err != nil {
324+ return connectError(ctx, err)
325+ }
326+ err = s.rel.DownloadAsset(ctx, caller(ctx), repo, ref, req.Msg.GetOffset(), -1, func(c ports.AssetChunk) error {
327+ m := &pb.DownloadReleaseAssetResponse{Chunk: c.Data}
328+ if c.Asset != nil {
329+ m.Asset = assetToPB(*c.Asset)
330+ }
331+ return stream.Send(m)
332+ })
333+ return connectError(ctx, err)
334+}
335+
336+func (s *releaseService) ListReleaseAssets(ctx context.Context, req *connect.Request[pb.ListReleaseAssetsRequest]) (*connect.Response[pb.ListReleaseAssetsResponse], error) {
337+ repo, err := repoOf(req.Msg.GetRepo())
338+ if err != nil {
339+ return nil, connectError(ctx, err)
340+ }
341+ assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, req.Msg.GetReleaseId())
342+ if err != nil {
343+ return nil, connectError(ctx, err)
344+ }
345+ return connect.NewResponse(&pb.ListReleaseAssetsResponse{Assets: assetsToPB(assets)}), nil
346+}
347+
348+func (s *releaseService) GetReleaseUsage(ctx context.Context, req *connect.Request[pb.GetReleaseUsageRequest]) (*connect.Response[pb.GetReleaseUsageResponse], error) {
349+ repo, err := repoOf(req.Msg.GetRepo())
350+ if err != nil {
351+ return nil, connectError(ctx, err)
352+ }
353+ u, err := s.rel.Usage(ctx, caller(ctx), repo)
354+ if err != nil {
355+ return nil, connectError(ctx, err)
356+ }
357+ return connect.NewResponse(&pb.GetReleaseUsageResponse{UsedBytes: u.Used, QuotaBytes: u.Limits.Quota, AssetMaxBytes: u.Limits.AssetMax, AssetsMax: int32(u.Limits.AssetsMax)}), nil
358+}
A internal/adapters/rpc/release_test.go +472 -0
@@ -0,0 +1,472 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "net/http/httptest"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+ "google.golang.org/protobuf/proto"
18+ "google.golang.org/protobuf/types/known/fieldmaskpb"
19+
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
25+)
26+
27+func releaseClient(t *testing.T, rel ports.ReleaseService) (ermiteconnect.ReleaseServiceClient, ermiteconnect.ServerServiceClient) {
28+ t.Helper()
29+ srv := httptest.NewUnstartedServer(New(Config{Releases: rel, Sessions: mocks.NewSessionService(t), SigningKey: mocks.NewSigningKeyService(t)}).Handler())
30+ srv.EnableHTTP2 = true
31+ srv.StartTLS()
32+ t.Cleanup(srv.Close)
33+ return ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL), ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
34+}
35+
36+func TestReleaseRPCUnary(t *testing.T) {
37+ ctx := context.Background()
38+ repo := mustRepoName(t, "demo")
39+ now := time.Now().UTC().Truncate(time.Second)
40+ r := domain.Release{ID: 7, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", Prerelease: true, TargetCommitish: "abc", Author: "owner", CreatedAt: now, PublishedAt: now, UpdatedAt: now}
41+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Repo: repo, Name: "a.zip", Label: "label", ContentType: "application/zip", Size: 3, Digest: "sha256:123", Uploader: "owner", CreatedAt: now, UpdatedAt: now}
42+ expected := releaseToPB(r)
43+ expected.Latest = true
44+ expected.Assets = []*pb.ReleaseAsset{assetToPB(a)}
45+ for _, method := range []string{"create", "get id", "get tag", "get latest", "list", "update", "delete", "update asset", "delete asset", "list assets", "usage"} {
46+ t.Run(method, func(t *testing.T) {
47+ rel := mocks.NewReleaseService(t)
48+ c, _ := releaseClient(t, rel)
49+ p := domain.Anonymous()
50+ hydrate := func() {
51+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(r, nil).Once()
52+ rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return([]domain.ReleaseAsset{a}, nil).Once()
53+ }
54+ switch method {
55+ case "create":
56+ rel.EXPECT().CreateRelease(mock.Anything, p, repo, ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}).Return(r, nil).Once()
57+ hydrate()
58+ res, err := c.CreateRelease(ctx, connect.NewRequest(&pb.CreateReleaseRequest{Repo: "demo", Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}))
59+ require.NoError(t, err)
60+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
61+ case "get id", "get tag", "get latest":
62+ req := &pb.GetReleaseRequest{Repo: "demo"}
63+ ref := ports.ReleaseRef{}
64+ switch method {
65+ case "get id":
66+ req.Selector = &pb.GetReleaseRequest_Id{Id: 7}
67+ ref.ID = 7
68+ case "get tag":
69+ req.Selector = &pb.GetReleaseRequest_Tag{Tag: "v1"}
70+ ref.Tag = "v1"
71+ default:
72+ req.Selector = &pb.GetReleaseRequest_Latest{Latest: true}
73+ ref.Latest = true
74+ }
75+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ref).Return(r, nil).Once()
76+ hydrate()
77+ res, err := c.GetRelease(ctx, connect.NewRequest(req))
78+ require.NoError(t, err)
79+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
80+ case "list":
81+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 17, "cursor").Return(ports.ReleasePage{Releases: []domain.Release{r}, NextPageToken: "next"}, nil).Once()
82+ hydrate()
83+ res, err := c.ListReleases(ctx, connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo", PageSize: 17, PageToken: "cursor"}))
84+ require.NoError(t, err)
85+ require.Len(t, res.Msg.Releases, 1)
86+ assert.True(t, proto.Equal(expected, res.Msg.Releases[0]))
87+ assert.Equal(t, "next", res.Msg.NextPageToken)
88+ case "update":
89+ rel.EXPECT().UpdateRelease(mock.Anything, p, repo, int64(7), ports.UpdateReleaseParams{Title: new(""), Draft: new(false)}).Return(r, nil).Once()
90+ hydrate()
91+ res, err := c.UpdateRelease(ctx, connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", Id: 7, Tag: "ignored", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "draft"}}}))
92+ require.NoError(t, err)
93+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
94+ case "delete":
95+ rel.EXPECT().DeleteRelease(mock.Anything, p, repo, int64(7)).Return(nil).Once()
96+ _, err := c.DeleteRelease(ctx, connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7}))
97+ require.NoError(t, err)
98+ case "update asset":
99+ rel.EXPECT().UpdateAsset(mock.Anything, p, repo, int64(9), ports.UpdateAssetParams{Name: new("new.zip"), Label: new("")}).Return(a, nil).Once()
100+ res, err := c.UpdateReleaseAsset(ctx, connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo", AssetId: 9, Name: "new.zip", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name", "label"}}}))
101+ require.NoError(t, err)
102+ assert.True(t, proto.Equal(assetToPB(a), res.Msg.GetAsset()))
103+ case "delete asset":
104+ rel.EXPECT().DeleteAsset(mock.Anything, p, repo, int64(9)).Return(nil).Once()
105+ _, err := c.DeleteReleaseAsset(ctx, connect.NewRequest(&pb.DeleteReleaseAssetRequest{Repo: "demo", AssetId: 9}))
106+ require.NoError(t, err)
107+ case "list assets":
108+ rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(7)).Return([]domain.ReleaseAsset{a}, nil).Once()
109+ res, err := c.ListReleaseAssets(ctx, connect.NewRequest(&pb.ListReleaseAssetsRequest{Repo: "demo", ReleaseId: 7}))
110+ require.NoError(t, err)
111+ require.Len(t, res.Msg.Assets, 1)
112+ assert.True(t, proto.Equal(assetToPB(a), res.Msg.Assets[0]))
113+ case "usage":
114+ rel.EXPECT().Usage(mock.Anything, p, repo).Return(ports.ReleaseUsage{Used: 1, Limits: ports.ReleaseLimits{Quota: 2, AssetMax: 3, AssetsMax: 4}}, nil).Once()
115+ res, err := c.GetReleaseUsage(ctx, connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
116+ require.NoError(t, err)
117+ assert.True(t, proto.Equal(&pb.GetReleaseUsageResponse{UsedBytes: 1, QuotaBytes: 2, AssetMaxBytes: 3, AssetsMax: 4}, res.Msg))
118+ }
119+ })
120+ }
121+}
122+
123+func TestReleaseFeatureAndMount(t *testing.T) {
124+ for _, enabled := range []bool{false, true} {
125+ t.Run(map[bool]string{false: "absent", true: "present"}[enabled], func(t *testing.T) {
126+ var rel ports.ReleaseService
127+ if enabled {
128+ rel = mocks.NewReleaseService(t)
129+ }
130+ c, server := releaseClient(t, rel)
131+ res, err := server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
132+ require.NoError(t, err)
133+ assert.Equal(t, enabled, containsReleaseFeature(res.Msg.Features))
134+ assert.NotContains(t, Features, FeatureReleases)
135+ if !enabled {
136+ _, err := c.GetReleaseUsage(context.Background(), connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
137+ requireCode(t, connect.CodeUnimplemented, err)
138+ }
139+ })
140+ }
141+}
142+func containsReleaseFeature(features []string) bool {
143+ for _, f := range features {
144+ if f == FeatureReleases {
145+ return true
146+ }
147+ }
148+ return false
149+}
150+
151+func TestReleaseRPCUpload(t *testing.T) {
152+ for _, firstData := range []bool{false, true} {
153+ t.Run(map[bool]string{false: "header only", true: "header with data"}[firstData], func(t *testing.T) {
154+ rel := mocks.NewReleaseService(t)
155+ c, _ := releaseClient(t, rel)
156+ full := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
157+ tail := []byte("tail")
158+ want := append(bytes.Clone(full), tail...)
159+ rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.UploadAssetParams{ReleaseID: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
160+ got, err := io.ReadAll(r)
161+ assert.NoError(t, err)
162+ assert.Equal(t, want, got)
163+ return domain.ReleaseAsset{ID: 9, Size: int64(len(got))}, err
164+ }).Once()
165+ stream := c.UploadReleaseAsset(context.Background())
166+ first := &pb.UploadReleaseAssetRequest{Repo: "demo", ReleaseId: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}
167+ if firstData {
168+ first.Data = full
169+ }
170+ require.NoError(t, stream.Send(first))
171+ if !firstData {
172+ require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: full}))
173+ }
174+ require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: tail}))
175+ res, err := stream.CloseAndReceive()
176+ require.NoError(t, err)
177+ assert.EqualValues(t, 9, res.Msg.Asset.Id)
178+ assert.EqualValues(t, len(want), res.Msg.Asset.Size)
179+ })
180+ }
181+}
182+
183+func TestReleaseRPCUploadInvalid(t *testing.T) {
184+ tests := []struct {
185+ name string
186+ messages []*pb.UploadReleaseAssetRequest
187+ read bool
188+ code connect.Code
189+ }{
190+ {name: "empty", code: connect.CodeInvalidArgument},
191+ {name: "negative size", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Size: -1}}, code: connect.CodeInvalidArgument},
192+ {name: "bad repo", messages: []*pb.UploadReleaseAssetRequest{{Repo: "../bad"}}, code: connect.CodeInvalidArgument},
193+ {name: "first chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, domain.CIArtifactChunk+1)}}, code: connect.CodeInvalidArgument},
194+ {name: "later chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {Data: make([]byte, domain.CIArtifactChunk+1)}}, read: true, code: connect.CodeInvalidArgument},
195+ {name: "message exceeds envelope", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, RunnerMaxRequestBytes+1)}}, code: connect.CodeResourceExhausted},
196+ }
197+ for _, m := range []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {ReleaseId: 1}, {Name: "a"}, {Label: "l"}, {ContentType: "text/plain"}, {Size: 1}, {Replace: true}} {
198+ tests = append(tests, struct {
199+ name string
200+ messages []*pb.UploadReleaseAssetRequest
201+ read bool
202+ code connect.Code
203+ }{name: "repeated metadata " + m.String(), messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, m}, read: true, code: connect.CodeInvalidArgument})
204+ }
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ rel := mocks.NewReleaseService(t)
208+ c, _ := releaseClient(t, rel)
209+ if tt.read {
210+ rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
211+ _, err := io.ReadAll(r)
212+ return domain.ReleaseAsset{}, err
213+ }).Once()
214+ }
215+ stream := c.UploadReleaseAsset(context.Background())
216+ for _, m := range tt.messages {
217+ if err := stream.Send(m); err != nil {
218+ break
219+ }
220+ }
221+ _, err := stream.CloseAndReceive()
222+ requireCode(t, tt.code, err)
223+ })
224+ }
225+}
226+
227+func TestReleaseRPCDownload(t *testing.T) {
228+ for _, selector := range []string{"id", "tag", "latest"} {
229+ t.Run(selector, func(t *testing.T) {
230+ rel := mocks.NewReleaseService(t)
231+ c, _ := releaseClient(t, rel)
232+ req := &pb.DownloadReleaseAssetRequest{Repo: "demo", Offset: 3}
233+ ref := ports.AssetRef{}
234+ switch selector {
235+ case "id":
236+ req.Selector = &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}
237+ ref.ID = 9
238+ case "tag":
239+ req.Selector = &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1", Name: "a"}}
240+ ref.Tag = "v1"
241+ ref.Name = "a"
242+ default:
243+ req.Selector = &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Latest: true, Name: "a"}}
244+ ref.Latest = true
245+ ref.Name = "a"
246+ }
247+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "a", Size: 8}
248+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ref, int64(3), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
249+ if err := send(ports.AssetChunk{Asset: &a}); err != nil {
250+ return err
251+ }
252+ if err := send(ports.AssetChunk{Data: []byte("12")}); err != nil {
253+ return err
254+ }
255+ return send(ports.AssetChunk{Data: []byte("345")})
256+ }).Once()
257+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(req))
258+ require.NoError(t, err)
259+ defer func() { _ = stream.Close() }()
260+ require.True(t, stream.Receive())
261+ assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
262+ assert.Empty(t, stream.Msg().Chunk)
263+ var got []byte
264+ for stream.Receive() {
265+ assert.Nil(t, stream.Msg().Asset)
266+ got = append(got, stream.Msg().Chunk...)
267+ }
268+ require.NoError(t, stream.Err())
269+ assert.Equal(t, "12345", string(got))
270+ })
271+ }
272+}
273+
274+func TestReleaseRPCErrors(t *testing.T) {
275+ for _, tt := range []struct {
276+ err error
277+ code connect.Code
278+ }{{domain.ErrNotFound, connect.CodeNotFound}, {domain.ErrAlreadyExists, connect.CodeAlreadyExists}, {domain.ErrResourceExhausted, connect.CodeResourceExhausted}, {domain.ErrForbidden, connect.CodePermissionDenied}, {domain.ErrOutOfRange, connect.CodeOutOfRange}, {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition}, {domain.ErrInvalid, connect.CodeInvalidArgument}} {
279+ t.Run(tt.code.String(), func(t *testing.T) {
280+ rel := mocks.NewReleaseService(t)
281+ c, _ := releaseClient(t, rel)
282+ rel.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.ReleaseRef{ID: 7}).Return(domain.Release{}, tt.err).Once()
283+ _, err := c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
284+ requireCode(t, tt.code, err)
285+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).Return(tt.err).Once()
286+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
287+ if err == nil {
288+ defer func() { _ = stream.Close() }()
289+ assert.False(t, stream.Receive())
290+ err = stream.Err()
291+ }
292+ requireCode(t, tt.code, err)
293+ })
294+ }
295+}
296+
297+func TestReleaseMasksAndSelectors(t *testing.T) {
298+ paths := []string{"tag", "target_commitish", "title", "notes", "draft", "prerelease", "make_latest"}
299+ p, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
300+ require.NoError(t, err)
301+ assert.Equal(t, ports.UpdateReleaseParams{Tag: new(""), TargetCommitish: new(""), Title: new(""), Notes: new(""), Draft: new(false), Prerelease: new(false), MakeLatest: new(false)}, p)
302+ for _, paths := range [][]string{nil, {"unknown"}, {"*"}, {"title", "assets"}} {
303+ _, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
304+ assert.ErrorIs(t, err, domain.ErrInvalid)
305+ _, err = assetUpdateParams(&pb.UpdateReleaseAssetRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
306+ assert.ErrorIs(t, err, domain.ErrInvalid)
307+ }
308+ for _, req := range []*pb.GetReleaseRequest{{}, {Selector: &pb.GetReleaseRequest_Id{Id: -1}}, {Selector: &pb.GetReleaseRequest_Tag{}}, {Selector: &pb.GetReleaseRequest_Latest{Latest: false}}} {
309+ _, err := releaseRef(req)
310+ assert.ErrorIs(t, err, domain.ErrInvalid)
311+ }
312+ for _, req := range []*pb.DownloadReleaseAssetRequest{{}, {Selector: &pb.DownloadReleaseAssetRequest_AssetId{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1"}}}, {Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}} {
313+ _, err := assetRef(req)
314+ assert.ErrorIs(t, err, domain.ErrInvalid)
315+ }
316+}
317+
318+func TestReleaseEvents(t *testing.T) {
319+ r := domain.Release{ID: 7, Repo: mustRepoName(t, "demo"), Tag: "v1", Draft: true, Prerelease: true}
320+ at := time.Now().UTC()
321+ events := []domain.Event{domain.ReleasePublished{Release: r, At: at}, domain.ReleaseUpdated{Release: r, At: at}, domain.ReleaseDeleted{Release: r, At: at}, domain.ReleaseUnpublished{Release: r, At: at}}
322+ expected := []*pb.Event{
323+ {Kind: &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
324+ {Kind: &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
325+ {Kind: &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: "demo", Id: 7, Tag: "v1"}}},
326+ {Kind: &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
327+ }
328+ for i, ev := range events {
329+ t.Run(ev.EventKind(), func(t *testing.T) {
330+ expected[i].Id = "event"
331+ expected[i].Time = timestamp(at)
332+ assert.True(t, proto.Equal(expected[i], watchEventToPB(ports.WatchEvent{ID: "event", Event: ev})))
333+ })
334+ }
335+}
336+
337+func TestReleaseResponseEnrichment(t *testing.T) {
338+ repo := mustRepoName(t, "demo")
339+ for _, tt := range []struct {
340+ name string
341+ latest domain.Release
342+ latestErr, assetsErr error
343+ code connect.Code
344+ wantLatest bool
345+ }{
346+ {name: "draft without latest", latestErr: domain.ErrNotFound},
347+ {name: "different latest", latest: domain.Release{ID: 8}},
348+ {name: "current latest", latest: domain.Release{ID: 7}, wantLatest: true},
349+ {name: "latest lookup fails", latestErr: domain.ErrUnavailable, code: connect.CodeUnavailable},
350+ {name: "assets lookup fails", assetsErr: domain.ErrNotFound, code: connect.CodeNotFound},
351+ } {
352+ t.Run(tt.name, func(t *testing.T) {
353+ rel := mocks.NewReleaseService(t)
354+ rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{ID: 7}).Return(domain.Release{ID: 7, Repo: repo, Draft: true}, nil).Once()
355+ rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{Latest: true}).Return(tt.latest, tt.latestErr).Once()
356+ if tt.code != connect.CodeUnavailable {
357+ rel.EXPECT().ListAssets(mock.Anything, domain.Owner(), repo, int64(7)).Return(nil, tt.assetsErr).Once()
358+ }
359+ res, err := (&releaseService{rel}).GetRelease(ownerCtx(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
360+ if tt.code != 0 {
361+ requireCode(t, tt.code, err)
362+ return
363+ }
364+ require.NoError(t, err)
365+ assert.Equal(t, tt.wantLatest, res.Msg.Release.Latest)
366+ assert.Empty(t, res.Msg.Release.Assets)
367+ assert.Nil(t, res.Msg.Release.PublishedAt)
368+ })
369+ }
370+}
371+
372+func TestReleaseRPCAuthenticatedCaller(t *testing.T) {
373+ rel := mocks.NewReleaseService(t)
374+ auth := mocks.NewAuthService(t)
375+ auth.EXPECT().Authenticate(mock.Anything, "secret").Return(domain.Owner(), nil).Once()
376+ rel.EXPECT().DeleteRelease(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(7)).Return(nil).Once()
377+ srv := httptest.NewServer(New(Config{Releases: rel, Auth: auth, Sessions: mocks.NewSessionService(t)}).Handler())
378+ defer srv.Close()
379+ c := ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL)
380+ req := connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7})
381+ req.Header().Set("Authorization", "Bearer secret")
382+ _, err := c.DeleteRelease(context.Background(), req)
383+ require.NoError(t, err)
384+}
385+
386+func TestReleaseRPCDownloadFullChunk(t *testing.T) {
387+ rel := mocks.NewReleaseService(t)
388+ c, _ := releaseClient(t, rel)
389+ data := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
390+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
391+ if err := send(ports.AssetChunk{Asset: &domain.ReleaseAsset{ID: 9, Size: int64(len(data))}}); err != nil {
392+ return err
393+ }
394+ return send(ports.AssetChunk{Data: data})
395+ }).Once()
396+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
397+ require.NoError(t, err)
398+ defer func() { _ = stream.Close() }()
399+ require.True(t, stream.Receive())
400+ require.NotNil(t, stream.Msg().Asset)
401+ require.True(t, stream.Receive())
402+ assert.Equal(t, data, stream.Msg().Chunk)
403+ assert.False(t, stream.Receive())
404+ require.NoError(t, stream.Err())
405+}
406+
407+func TestReleaseRPCRejectsInvalidMasksAndSelectors(t *testing.T) {
408+ c, _ := releaseClient(t, mocks.NewReleaseService(t))
409+ _, err := c.UpdateRelease(context.Background(), connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"unknown"}}}))
410+ requireCode(t, connect.CodeInvalidArgument, err)
411+ _, err = c.UpdateReleaseAsset(context.Background(), connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo"}))
412+ requireCode(t, connect.CodeInvalidArgument, err)
413+ _, err = c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Latest{Latest: false}}))
414+ requireCode(t, connect.CodeInvalidArgument, err)
415+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}))
416+ if err == nil {
417+ defer func() { _ = stream.Close() }()
418+ assert.False(t, stream.Receive())
419+ err = stream.Err()
420+ }
421+ requireCode(t, connect.CodeInvalidArgument, err)
422+}
423+
424+func TestReleaseRPCListResolvesLatestOnce(t *testing.T) {
425+ repo := mustRepoName(t, "demo")
426+ rel := mocks.NewReleaseService(t)
427+ c, _ := releaseClient(t, rel)
428+ p := domain.Anonymous()
429+ rows := []domain.Release{{ID: 3, Repo: repo, Tag: "c"}, {ID: 2, Repo: repo, Tag: "b"}, {ID: 1, Repo: repo, Tag: "a"}}
430+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: rows}, nil).Once()
431+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(rows[1], nil).Once()
432+ for _, r := range rows {
433+ rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return(nil, nil).Once()
434+ }
435+ res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
436+ require.NoError(t, err)
437+ require.Len(t, res.Msg.Releases, 3)
438+ for _, m := range res.Msg.Releases {
439+ assert.Equal(t, m.Id == 2, m.Latest, m.Tag)
440+ }
441+}
442+
443+func TestReleaseRPCListWithoutLatest(t *testing.T) {
444+ repo := mustRepoName(t, "demo")
445+ rel := mocks.NewReleaseService(t)
446+ c, _ := releaseClient(t, rel)
447+ p := domain.Anonymous()
448+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: []domain.Release{{ID: 1, Repo: repo}}}, nil).Once()
449+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound).Once()
450+ rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(1)).Return(nil, nil).Once()
451+ res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
452+ require.NoError(t, err)
453+ require.Len(t, res.Msg.Releases, 1)
454+ assert.False(t, res.Msg.Releases[0].Latest)
455+}
456+
457+func TestReleaseRPCDownloadZeroByteAsset(t *testing.T) {
458+ rel := mocks.NewReleaseService(t)
459+ c, _ := releaseClient(t, rel)
460+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "empty", Size: 0}
461+ // The use case sends the metadata and no data for an empty asset.
462+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
463+ return send(ports.AssetChunk{Asset: &a})
464+ }).Once()
465+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
466+ require.NoError(t, err)
467+ defer func() { _ = stream.Close() }()
468+ require.True(t, stream.Receive())
469+ assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
470+ assert.False(t, stream.Receive())
471+ require.NoError(t, stream.Err())
472+}
M internal/adapters/web/fragments.go +8 -2
@@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool {
6262 return true
6363 }
6464 switch we.Event.(type) {
65- case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
65+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished,
66+ domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6667 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6768 domain.RunUpdated, domain.JobUpdated,
6869 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
@@ -164,6 +165,11 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
164165 // repoPage handles the events of the topic of one repository.
165166 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
166167 switch ev := we.Event.(type) {
168+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
169+ if l.view == "releases" || l.view == "release" {
170+ return gap, nil // notes, assets, latest or visibility changed: reload
171+ }
172+ return nil, nil
167173 case domain.RunUpdated:
168174 return l.runEvent(ctx, ev)
169175 case domain.JobUpdated:
@@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
179185 return gap, nil // no longer visible
180186 case err != nil:
181187 return nil, err
182- case l.view == "summary", l.view == "refs":
188+ case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release":
183189 return gap, nil // the description, the default branch
184190 }
185191 return nil, nil
M internal/adapters/web/fragments_test.go +20 -0
@@ -8,6 +8,7 @@ import (
88 "testing"
99
1010 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
1112
1213 "github.com/a-h/templ"
1314 "github.com/stretchr/testify/assert"
@@ -99,3 +100,22 @@ func TestMRPushedHeadSignal(t *testing.T) {
99100 }
100101 }
101102 }
103+
104+// Release lifecycle events invalidate the release pages without putting notes
105+// or asset metadata in SSE payloads. Other views need no release fragments.
106+func TestReleaseLiveUpdates(t *testing.T) {
107+ for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108+ we := ports.WatchEvent{Event: ev}
109+ require.True(t, liveEvent(we), ev.EventKind())
110+ for _, view := range []string{"releases", "release", "summary", "refs", "mr"} {
111+ st := &liveStream{repo: "proj", view: view}
112+ got, err := st.render(t.Context(), we)
113+ require.NoError(t, err)
114+ if view == "release" || view == "releases" {
115+ assert.Equal(t, gap, got)
116+ } else {
117+ assert.Empty(t, got)
118+ }
119+ }
120+ }
121+}
A internal/adapters/web/releases.go +244 -0
@@ -0,0 +1,244 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/httprange"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func releasesBase(repo string) string { return repoHref(repo, "releases") }
24+func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) }
25+func releaseAssetHref(repo string, id int64) string {
26+ return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10)
27+}
28+
29+// ReleaseRow describes a release without mistaking MakeLatest (a pin) for
30+// the latest release selected by the service.
31+type ReleaseRow struct {
32+ Tag, Title, Href, Published string
33+ Draft, Prerelease, Latest bool
34+ AssetCount int
35+}
36+type ReleaseListPage struct {
37+ Rows []ReleaseRow
38+ Older string
39+}
40+type ReleaseAssetRow struct{ Name, Href, Size, ContentType string }
41+type ReleaseDetailPage struct {
42+ ReleaseRow
43+ Notes template.HTML
44+ SHA, CommitHref, SourceHref string
45+ Assets []ReleaseAssetRow
46+}
47+
48+func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) {
49+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return 0, nil
52+ }
53+ return rel.ID, err
54+}
55+func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow {
56+ published := "draft"
57+ if !rel.Draft && !rel.PublishedAt.IsZero() {
58+ published = rel.PublishedAt.UTC().Format("2006-01-02")
59+ }
60+ title := rel.Title
61+ if title == "" {
62+ title = rel.Tag
63+ }
64+ return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest}
65+}
66+func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
67+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
68+ if err != nil {
69+ return nil, 0, err
70+ }
71+ page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page"))
72+ if err != nil {
73+ return nil, 0, err
74+ }
75+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
76+ if err != nil {
77+ return nil, 0, err
78+ }
79+ p := ReleaseListPage{}
80+ for _, rel := range page.Releases {
81+ row := releaseRow(d.name(), rel, latest)
82+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
83+ if err != nil {
84+ return nil, 0, err
85+ }
86+ row.AssetCount = len(assets)
87+ p.Rows = append(p.Rows, row)
88+ }
89+ if page.NextPageToken != "" {
90+ p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken)
91+ }
92+ l := s.repoLayout(ctx, d, "releases")
93+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases")
94+ l.Title = "Releases · " + d.name()
95+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true})
96+ return Releases(l, p), http.StatusOK, nil
97+}
98+func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
99+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
100+ if err != nil {
101+ return nil, 0, err
102+ }
103+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")})
104+ if err != nil {
105+ return nil, 0, err
106+ }
107+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
108+ if err != nil {
109+ return nil, 0, err
110+ }
111+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
112+ if err != nil {
113+ return nil, 0, err
114+ }
115+ notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "")
116+ if err != nil {
117+ return nil, 0, err
118+ }
119+ p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)}
120+ if rel.TargetCommitish != "" {
121+ p.SHA = short(rel.TargetCommitish)
122+ p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish)
123+ }
124+ for _, a := range assets {
125+ p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType})
126+ }
127+ l := s.repoLayout(ctx, d, "releases")
128+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release")
129+ l.Title = rel.Tag + " · " + d.name()
130+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true})
131+ return ReleaseDetail(l, p), http.StatusOK, nil
132+}
133+func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) {
134+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
135+ defer cancel()
136+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
137+ if err != nil {
138+ s.fail(ctx, w, r, err)
139+ return
140+ }
141+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
142+ if err != nil {
143+ s.fail(ctx, w, r, err)
144+ return
145+ }
146+ cacheHeaders(ctx, w.Header(), "no-cache", sessionCache)
147+ http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther)
148+}
149+
150+func releaseHeaders(h http.Header, a domain.ReleaseAsset) {
151+ // Reuse the artifact sandbox and attachment policy; never trust an
152+ // uploader's media type to execute content in the forge's origin.
153+ typ := "application/octet-stream"
154+ if a.ContentType == domain.CIArtifactZip {
155+ typ = domain.CIArtifactZip
156+ }
157+ h.Set("Content-Type", typ)
158+ h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name)))
159+ h.Set("X-Content-Type-Options", "nosniff")
160+ h.Set("Content-Security-Policy", rawCSP)
161+ h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
162+ h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`)
163+ h.Set("Accept-Ranges", "bytes")
164+}
165+func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) {
166+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute)
167+ defer cancel()
168+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
169+ if err != nil {
170+ s.fail(ctx, w, r, errNotFound)
171+ return
172+ }
173+ ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")}
174+ if id := r.PathValue("id"); id != "" {
175+ ref.ID, err = strconv.ParseInt(id, 10, 64)
176+ if err != nil || ref.ID < 1 {
177+ s.fail(ctx, w, r, errNotFound)
178+ return
179+ }
180+ } else if ref.Tag == "" {
181+ ref.Latest = true
182+ }
183+ a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref)
184+ if err != nil {
185+ s.fail(ctx, w, r, errNotFound)
186+ return
187+ }
188+ // Resolve latest/tag only once: a moving latest cannot select another
189+ // asset between reading the headers and streaming the bytes.
190+ ref = ports.AssetRef{ID: a.ID}
191+ h := w.Header()
192+ releaseHeaders(h, a)
193+ cacheHeaders(ctx, h, "no-cache", sessionCache)
194+ offset, length, partial, err := httprange.Select(r, a.Size, h.Get("ETag"))
195+ if err != nil {
196+ h.Set("Content-Range", httprange.Unsatisfied(a.Size))
197+ h.Set("Content-Length", "0")
198+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
199+ return
200+ }
201+ status := http.StatusOK
202+ if partial {
203+ status = http.StatusPartialContent
204+ h.Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
205+ }
206+ h.Set("Content-Length", strconv.FormatInt(length, 10))
207+ if r.Method == http.MethodHead {
208+ w.WriteHeader(status)
209+ return
210+ }
211+ started := false
212+ rc := http.NewResponseController(w)
213+ defer func() { _ = rc.SetWriteDeadline(time.Time{}) }()
214+ err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error {
215+ if !started {
216+ if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size {
217+ return errors.New("web: release asset changed before download")
218+ }
219+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
220+ w.WriteHeader(status)
221+ started = true
222+ }
223+ if len(c.Data) == 0 {
224+ return nil
225+ }
226+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
227+ _, err := w.Write(c.Data)
228+ return err
229+ })
230+ if err == nil {
231+ return
232+ }
233+ if !started {
234+ // Do not leave binary headers or range metadata on the HTML error page.
235+ for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} {
236+ h.Del(key)
237+ }
238+ s.fail(ctx, w, r, errNotFound)
239+ return
240+ }
241+ log := zerowrap.FromCtx(ctx)
242+ log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed")
243+ panic(http.ErrAbortHandler)
244+}
A internal/adapters/web/releases.templ +67 -0
@@ -0,0 +1,67 @@
1+package web
2+
3+// ReleaseBadges are textual as well as visual, including the computed latest.
4+templ ReleaseBadges(r ReleaseRow) {
5+ if r.Draft {
6+ <span class="pill">Draft</span>
7+ }
8+ if r.Prerelease {
9+ <span class="pill">Pre-release</span>
10+ }
11+ if r.Latest {
12+ <span class="pill">Latest</span>
13+ }
14+}
15+
16+templ Releases(l Layout, p ReleaseListPage) {
17+ @Shell(l, nil) {
18+ <h1>Releases</h1>
19+ if len(p.Rows) == 0 {
20+ <p class="dim">no releases yet.</p>
21+ } else {
22+ <table class="list" aria-label="Releases">
23+ <thead><tr><th>tag</th><th>title</th><th>published</th><th>assets</th></tr></thead>
24+ <tbody>
25+ for _, r := range p.Rows {
26+ <tr>
27+ <td><a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a></td>
28+ <td>{ r.Title } @ReleaseBadges(r)</td>
29+ <td>{ r.Published }</td><td>{ itoa(r.AssetCount) }</td>
30+ </tr>
31+ }
32+ </tbody>
33+ </table>
34+ }
35+ if p.Older != "" {
36+ <p class="more"><a href={ templ.SafeURL(p.Older) } rel="next">older releases</a></p>
37+ }
38+ }
39+}
40+
41+templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42+ @Shell(l, nil) {
43+ <h1>{ p.Title }</h1>
44+ <p>
45+ <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46+ if p.SHA != "" {
47+ <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48+ }
49+ <span class="dim">{ p.Published }</span>
50+ </p>
51+ <div class="md">@templ.Raw(string(p.Notes))</div>
52+ <h2>Assets</h2>
53+ if len(p.Assets) == 0 {
54+ <p class="dim">no assets.</p>
55+ } else {
56+ <table class="list" aria-label="Release assets">
57+ <thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead>
58+ <tbody>
59+ for _, a := range p.Assets {
60+ <tr><td><a href={ templ.SafeURL(a.Href) } download>{ a.Name }</a></td><td>{ a.Size }</td><td>{ a.ContentType }</td></tr>
61+ }
62+ </tbody>
63+ </table>
64+ }
65+ <p><a href={ templ.SafeURL(p.SourceHref) }>Source</a></p>
66+ }
67+}
A internal/adapters/web/releases_templ.go +439 -0 generated file: collapsed
A internal/adapters/web/releases_test.go +298 -0
@@ -0,0 +1,298 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "mime"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/url"
12+ "strings"
13+ "testing"
14+ "time"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+)
24+
25+type releaseFixture struct {
26+ srv *Server
27+ svc *mocks.ReleaseService
28+ published, draft domain.Release
29+ asset domain.ReleaseAsset
30+ data string
31+ downloadErr error
32+ failAfterMetadata bool
33+ downloads int
34+ tokens []string
35+}
36+
37+func newReleaseFixture(t *testing.T) *releaseFixture {
38+ t.Helper()
39+ repo := repoName(t, "proj")
40+ f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
42+ f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
43+ f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
44+ f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
45+ if ref.Latest || ref.Tag == f.published.Tag {
46+ return f.published, nil
47+ }
48+ if ref.Tag == f.draft.Tag && p.IsOwner() {
49+ return f.draft, nil
50+ }
51+ return domain.Release{}, domain.ErrNotFound
52+ }).Maybe()
53+ f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) {
54+ f.tokens = append(f.tokens, token)
55+ rows := []domain.Release{f.published}
56+ if p.IsOwner() {
57+ rows = append([]domain.Release{f.draft}, rows...)
58+ }
59+ return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil
60+ }).Maybe()
61+ f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
62+ if id == f.published.ID {
63+ return []domain.ReleaseAsset{f.asset}, nil
64+ }
65+ if id == f.draft.ID && p.IsOwner() {
66+ return nil, nil
67+ }
68+ return nil, domain.ErrNotFound
69+ }).Maybe()
70+ f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
71+ if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) {
72+ return f.asset, nil
73+ }
74+ return domain.ReleaseAsset{}, domain.ErrNotFound
75+ }).Maybe()
76+ f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
77+ f.downloads++
78+ deadline, ok := ctx.Deadline()
79+ require.True(t, ok)
80+ assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2)
81+ if f.downloadErr != nil && !f.failAfterMetadata {
82+ return f.downloadErr
83+ }
84+ if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil {
85+ return err
86+ }
87+ if f.downloadErr != nil {
88+ return f.downloadErr
89+ }
90+ return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])})
91+ }).Maybe()
92+ browse := mocks.NewBrowseService(t)
93+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
94+ f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }})
95+ return f
96+}
97+func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder {
98+ r := httptest.NewRequest(method, path, nil)
99+ if cookie != "" {
100+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
101+ }
102+ if byteRange != "" {
103+ r.Header.Set("Range", byteRange)
104+ }
105+ w := httptest.NewRecorder()
106+ f.srv.ServeHTTP(w, r)
107+ return w
108+}
109+func TestReleasePages(t *testing.T) {
110+ f := newReleaseFixture(t)
111+ for _, path := range []string{"/proj/releases", "/proj/releases/"} {
112+ w := f.request("GET", path, "", "")
113+ require.Equal(t, 200, w.Code)
114+ body := w.Body.String()
115+ assert.Contains(t, body, "First release")
116+ assert.NotContains(t, body, "Secret draft")
117+ assert.Contains(t, body, ">Latest</span>")
118+ assert.Contains(t, body, ">Pre-release</span>")
119+ assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`)
120+ assert.Contains(t, body, "2026-10-12")
121+ assert.Contains(t, body, `<td>1</td>`)
122+ assert.Contains(t, body, `?page=next%2B%2F%3D`)
123+ assert.Contains(t, body, `aria-keyshortcuts="l"`)
124+ assert.Contains(t, body, "view=releases")
125+ }
126+ owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
127+ require.Equal(t, 200, owner.Code)
128+ assert.Contains(t, owner.Body.String(), "Secret draft")
129+ assert.Contains(t, owner.Body.String(), ">Draft</span>")
130+ assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
131+ assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
132+ // The pin on the draft must not become a computed latest badge.
133+ assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
134+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
135+ require.Equal(t, 200, w.Code)
136+ body := w.Body.String()
137+ for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138+ assert.Contains(t, body, text)
139+ }
140+ assert.NotContains(t, body, "<script>alert")
141+ assert.NotContains(t, body, `href="javascript:`)
142+ assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
143+ for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
144+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
145+ }
146+ assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
147+ latest := f.request("GET", "/proj/releases/latest", "", "")
148+ assert.Equal(t, 303, latest.Code)
149+ assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
150+}
151+func TestReleaseDownloads(t *testing.T) {
152+ f := newReleaseFixture(t)
153+ // A simple tag uses the stable URL; slash tags use the page's asset-id link.
154+ f.published.Tag = "v1"
155+ for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
156+ for _, tc := range []struct {
157+ rangeValue, body, cr string
158+ status int
159+ }{
160+ {"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416},
161+ } {
162+ w := f.request("GET", path, "", tc.rangeValue)
163+ require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
164+ assert.Equal(t, tc.body, w.Body.String())
165+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
166+ assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
167+ assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
168+ assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
169+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
170+ assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
171+ _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
172+ require.NoError(t, err)
173+ assert.Equal(t, "build.zip", params["filename"])
174+ assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
175+ }
176+ before := f.downloads
177+ w := f.request("HEAD", path, "", "bytes=1-2")
178+ assert.Equal(t, 200, w.Code)
179+ assert.Empty(t, w.Header().Get("Content-Range"))
180+ assert.Empty(t, w.Body.String())
181+ assert.Equal(t, "10", w.Header().Get("Content-Length"))
182+ assert.Equal(t, before, f.downloads)
183+ }
184+ for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} {
185+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
186+ }
187+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
188+ r.Header.Set("Range", "bytes=1-2")
189+ r.Header.Set("If-Range", `"old"`)
190+ w := httptest.NewRecorder()
191+ f.srv.ServeHTTP(w, r)
192+ assert.Equal(t, 200, w.Code)
193+ assert.Equal(t, f.data, w.Body.String())
194+}
195+func TestReleaseDownloadIfRange(t *testing.T) {
196+ f := newReleaseFixture(t)
197+ etag := `"sha256-` + strings.Repeat("b", 64) + `"`
198+ for _, tc := range []struct {
199+ name, ifRange, body, cr string
200+ status int
201+ }{
202+ {"matching", etag, "2345", "bytes 2-5/10", 206},
203+ {"stale", `"old"`, "0123456789", "", 200},
204+ {"date", "Wed, 21 Oct 2026 12:00:00 GMT", "0123456789", "", 200},
205+ } {
206+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
207+ r.Header.Set("Range", "bytes=2-5")
208+ r.Header.Set("If-Range", tc.ifRange)
209+ w := httptest.NewRecorder()
210+ f.srv.ServeHTTP(w, r)
211+ assert.Equal(t, tc.status, w.Code, tc.name)
212+ assert.Equal(t, tc.body, w.Body.String(), tc.name)
213+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"), tc.name)
214+ }
215+}
216+
217+// A tag with a slash is one path segment once escaped: the download route must
218+// match it and resolve the tag with the slash.
219+func TestReleaseDownloadSlashTag(t *testing.T) {
220+ f := newReleaseFixture(t) // the published tag is "stable/v1"
221+ w := f.request("GET", "/proj/releases/download/"+url.PathEscape(f.published.Tag)+"/build.zip", "", "")
222+ require.Equal(t, 200, w.Code)
223+ assert.Equal(t, f.data, w.Body.String())
224+}
225+
226+func TestReleaseDownloadFailures(t *testing.T) {
227+ f := newReleaseFixture(t)
228+ f.downloadErr = errors.New("blob unavailable")
229+ w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
230+ assert.Equal(t, 404, w.Code)
231+ assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
232+ for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
233+ assert.Empty(t, w.Header().Get(key))
234+ }
235+ f.failAfterMetadata = true
236+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
237+}
238+func TestReleaseAttachmentHeaders(t *testing.T) {
239+ for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
240+ h := http.Header{}
241+ releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
242+ assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
243+ assert.NotContains(t, h.Get("Content-Disposition"), "\r")
244+ assert.NotContains(t, h.Get("Content-Disposition"), "\n")
245+ media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
246+ require.NoError(t, err)
247+ assert.Equal(t, "attachment", media)
248+ assert.Equal(t, attachmentName(name), params["filename"])
249+ }
250+}
251+func TestReleasesDisabled(t *testing.T) {
252+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
253+ for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
254+ w := httptest.NewRecorder()
255+ s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
256+ assert.Equal(t, 404, w.Code)
257+ }
258+}
259+
260+func TestReleaseEmptyDownloads(t *testing.T) {
261+ f := newReleaseFixture(t)
262+ f.asset.Size = 0
263+ f.data = ""
264+ w := f.request("GET", "/proj/releases/assets/7", "", "")
265+ assert.Equal(t, 200, w.Code)
266+ assert.Equal(t, "0", w.Header().Get("Content-Length"))
267+ assert.Empty(t, w.Body.String())
268+ w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
269+ assert.Equal(t, 416, w.Code)
270+ assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
271+}
272+
273+func TestReleaseLatestMissing(t *testing.T) {
274+ svc := mocks.NewReleaseService(t)
275+ repo := repoName(t, "proj")
276+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
277+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
278+ w := httptest.NewRecorder()
279+ srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
280+ assert.Equal(t, 404, w.Code)
281+ assert.Empty(t, w.Header().Get("Location"))
282+}
283+
284+func TestReleaseAssetNameEscapedInPage(t *testing.T) {
285+ f := newReleaseFixture(t)
286+ f.asset.Name = `<img src=x onerror=alert(1)>`
287+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
288+ require.Equal(t, 200, w.Code)
289+ assert.NotContains(t, w.Body.String(), f.asset.Name)
290+ assert.Contains(t, w.Body.String(), "&lt;img src=x onerror=alert(1)&gt;")
291+}
292+
293+func TestReleaseRowFallbacks(t *testing.T) {
294+ row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
295+ assert.Equal(t, "v1", row.Title)
296+ assert.Equal(t, "draft", row.Published)
297+ assert.False(t, row.Latest)
298+}
M internal/adapters/web/repo.go +3 -0
@@ -156,6 +156,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
156156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
157157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158158 }
159+ if s.cfg.Releases != nil {
160+ l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases"))
161+ }
159162 l.Sections = s.withActions(l.Sections, d.name(), section)
160163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
161164
M internal/adapters/web/web.go +11 -0
@@ -91,6 +91,8 @@ type Config struct {
9191 // their download and their deletion. It needs Runs. Without it there is
9292 // neither. Leave it unset rather than assigning a typed nil pointer.
9393 Artifacts ports.ArtifactService
94+ // Releases serves the release pages and streamed assets. Nil disables them.
95+ Releases ports.ReleaseService
9496 // Events is the source of the event stream behind /-/events. Without
9597 // it the pages are not live and the route answers 404. Leave it unset
9698 // rather than assigning a typed nil pointer.
@@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux {
252254 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
253255 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
254256 }
257+ if s.cfg.Releases != nil {
258+ mux.HandleFunc("GET /{repo}/releases", s.page(s.releaseList))
259+ mux.HandleFunc("GET /{repo}/releases/{$}", s.page(s.releaseList))
260+ mux.HandleFunc("GET /{repo}/releases/tag/{tag...}", s.page(s.releaseShow))
261+ mux.HandleFunc("GET /{repo}/releases/latest", s.releaseLatest)
262+ mux.HandleFunc("GET /{repo}/releases/download/{tag}/{asset}", s.releaseDownload)
263+ mux.HandleFunc("GET /{repo}/releases/latest/download/{asset}", s.releaseDownload)
264+ mux.HandleFunc("GET /{repo}/releases/assets/{id}", s.releaseDownload)
265+ }
255266 if s.cfg.Runs != nil {
256267 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
257268 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/ci_artifacts_test.go +110 -12
@@ -25,6 +25,7 @@ import (
2525
2626 pb "bnema.dev/ermite-api/gen/ermite"
2727 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
28+ "bnema.dev/ermite/internal/adapters/blobdisk"
2829 "bnema.dev/ermite/internal/adapters/ciartifacts"
2930 "bnema.dev/ermite/internal/domain"
3031 "bnema.dev/ermite/internal/ports"
@@ -48,6 +49,14 @@ jobs:
4849 - run: echo down
4950 `
5051
52+// newArtifactHarness uses the production blob wiring.
53+func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
54+ t.Helper()
55+ c := newCIHarness(t, tweak)
56+ c.artifactRoot = blobdisk.Dir(c.data)
57+ return c
58+}
59+
5160 // artifactAPI are the artifact clients of one caller.
5261 type artifactAPI struct {
5362 job ermiteconnect.JobArtifactServiceClient
@@ -202,11 +211,11 @@ func (c *ciHarness) usage() int64 {
202211 return used
203212 }
204213
205-// artifactFiles lists every file under ci/artifacts.
214+// artifactFiles lists the new CI objects under the test blob root.
206215 func (c *ciHarness) artifactFiles() []string {
207216 c.t.Helper()
208217 var out []string
209- root := ciartifacts.Dir(c.data)
218+ root := filepath.Join(c.artifactRoot, "objects", "ci")
210219 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
211220 if err == nil && !d.IsDir() {
212221 rel, _ := filepath.Rel(root, path)
@@ -249,7 +258,7 @@ func (c *ciHarness) newArtifactRun(r *fakeRunner, files map[string]string) (runI
249258 }
250259
251260 func TestCIArtifactsPassBetweenJobs(t *testing.T) {
252- c := newCIHarness(t, nil)
261+ c := newArtifactHarness(t, nil)
253262 runner := c.registerRunner("alpha", "ubuntu-latest")
254263 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
255264 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)
@@ -417,7 +426,7 @@ func TestCIArtifactsPassBetweenJobs(t *testing.T) {
417426 // AC3: a job token of another run reaches nothing of this run, and the calls
418427 // are refused once the job ended.
419428 func TestCIArtifactsAreIsolatedPerRunAndJob(t *testing.T) {
420- c := newCIHarness(t, nil)
429+ c := newArtifactHarness(t, nil)
421430 runner := c.registerRunner("alpha", "ubuntu-latest")
422431 runA, upA, downA := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
423432 _, upB, _ := c.newArtifactRun(runner, map[string]string{"README": "two"})
@@ -505,7 +514,7 @@ func (s *shiftClock) since(t time.Time) time.Duration { return s.Now().Sub(t) }
505514
506515 func TestCIArtifactLimits(t *testing.T) {
507516 const mib = 1 << 20
508- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
517+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
509518 cfg.ArtifactLimits = ports.ArtifactLimits{Retention: 10 * 24 * time.Hour, Quota: 3 * mib, MaxSize: 2 * mib}
510519 })
511520 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -567,7 +576,9 @@ func TestCIArtifactLimits(t *testing.T) {
567576
568577 func TestCIArtifactsSweep(t *testing.T) {
569578 clk := &shiftClock{}
570- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
579+ legacyRoot := ciartifacts.Dir(t.TempDir())
580+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
581+ cfg.ArtifactFiles = ciartifacts.New(legacyRoot)
571582 cfg.Clock = clk
572583 cfg.Tick = 20 * time.Millisecond
573584 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour
@@ -583,9 +594,10 @@ func TestCIArtifactsSweep(t *testing.T) {
583594 crashed := up.mustCreate("crashed")
584595 _, err := up.block(crashed, "x", []byte("stale"))
585596 require.NoError(t, err)
586- stray := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999)))
597+ stray := filepath.Join(legacyRoot, fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999)))
598+ require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700))
587599 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))
588- strayRun := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", int64(888888)))
600+ strayRun := filepath.Join(legacyRoot, fmt.Sprintf("%020d", int64(888888)))
589601 require.NoError(t, os.MkdirAll(strayRun, 0o700))
590602 start := clk.Now()
591603
@@ -619,7 +631,7 @@ func TestCIArtifactsSweep(t *testing.T) {
619631
620632 // Deleting the repository takes the records and the files of its artifacts.
621633 func TestCIArtifactsGoWithTheRepository(t *testing.T) {
622- c := newCIHarness(t, nil)
634+ c := newArtifactHarness(t, nil)
623635 runner := c.registerRunner("alpha", "ubuntu-latest")
624636 _, upJob, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
625637 a := c.jobArtifacts(upJob).upload("dist", []byte("x"))
@@ -648,7 +660,7 @@ func TestCIArtifactsOff(t *testing.T) {
648660 // before they are written, against the charges and the other writes.
649661 func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
650662 const kib, mib = 1 << 10, 1 << 20
651- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
663+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
652664 cfg.ArtifactLimits = ports.ArtifactLimits{Quota: 3 * mib, MaxSize: 2 * mib}
653665 })
654666 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -657,7 +669,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
657669
658670 blockFiles := func() (total int64) {
659671 for _, f := range c.artifactFiles() {
660- if info, err := os.Stat(filepath.Join(ciartifacts.Dir(c.data), f)); err == nil {
672+ if info, err := os.Stat(filepath.Join(c.artifactRoot, "objects", "ci", f)); err == nil {
661673 total += info.Size()
662674 }
663675 }
@@ -733,7 +745,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
733745 }
734746
735747 func TestCIArtifactCaps(t *testing.T) {
736- c := newCIHarness(t, nil)
748+ c := newArtifactHarness(t, nil)
737749 runner := c.registerRunner("alpha", "ubuntu-latest")
738750 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
739751 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)
@@ -787,3 +799,89 @@ func TestCIArtifactCaps(t *testing.T) {
787799 assert.True(t, bytes.Equal(content, body))
788800 })
789801 }
802+
803+// Both storage generations remain downloadable through the real RPC handlers.
804+func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
805+ // This test inserts legacy data after startup; supply its reader explicitly.
806+ legacyRoot := ciartifacts.Dir(t.TempDir())
807+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = ciartifacts.New(legacyRoot) })
808+ runner := c.registerRunner("alpha", "ubuntu-latest")
809+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
810+ up := c.jobArtifacts(job)
811+ current := up.upload("new", []byte("new blob"))
812+ store := c.forge.store.Artifacts()
813+ a, err := store.GetArtifact(c.ctx, current.GetId())
814+ require.NoError(t, err)
815+ assert.Equal(t, domain.CIArtifactStoreBlob, a.Store)
816+ path := filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))
817+ data, err := os.ReadFile(path)
818+ require.NoError(t, err)
819+ assert.Equal(t, "new blob", string(data))
820+ _, got, err := up.download(a.ID, 0)
821+ require.NoError(t, err)
822+ assert.Equal(t, data, got)
823+
824+ // Simulate an on-disk record written by the pre-upgrade server: no store
825+ // field and contents at the old id-only path, absent from the blob store.
826+ legacy := up.upload("old", []byte("old disk"))
827+ a, err = store.GetArtifact(c.ctx, legacy.GetId())
828+ require.NoError(t, err)
829+ oldPath := filepath.Join(legacyRoot, fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
830+ require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700))
831+ require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600))
832+ require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))))
833+ used, version, err := store.ArtifactUsage(c.ctx, a.Repo)
834+ require.NoError(t, err)
835+ a.Store = domain.CIArtifactStoreLegacy
836+ require.NoError(t, store.ApplyArtifacts(c.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
837+ _, got, err = up.download(a.ID, 0)
838+ require.NoError(t, err)
839+ assert.Equal(t, "old disk", string(got))
840+ require.NoError(t, up.remove("old"))
841+ _, err = os.Stat(oldPath)
842+ assert.True(t, os.IsNotExist(err))
843+}
844+
845+func TestCIArtifactWithoutLegacyReader(t *testing.T) {
846+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = nil })
847+ runner := c.registerRunner("alpha", "ubuntu-latest")
848+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
849+ up := c.jobArtifacts(job)
850+ a := up.upload("blob only", []byte("contents"))
851+ _, got, err := up.download(a.GetId(), 0)
852+ require.NoError(t, err)
853+ assert.Equal(t, "contents", string(got))
854+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.name(c.repo)))
855+ assert.Empty(t, c.artifactFiles())
856+}
857+
858+func TestCIArtifactMultipartAndOversizedBlock(t *testing.T) {
859+ c := newArtifactHarness(t, nil)
860+ runner := c.registerRunner("alpha", "ubuntu-latest")
861+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
862+ up := c.jobArtifacts(job)
863+ id := up.mustCreate("multipart")
864+ first, second := pattern(6<<20, 1), pattern(3<<20, 2)
865+ _, err := up.block(id, "a", first)
866+ require.NoError(t, err)
867+ _, err = up.block(id, "b", second)
868+ require.NoError(t, err)
869+ committed, err := up.commit(id, "a", "b")
870+ require.NoError(t, err)
871+ _, err = up.finalize("multipart", committed.GetDigest())
872+ require.NoError(t, err)
873+ _, got, err := up.download(id, 0)
874+ require.NoError(t, err)
875+ assert.Equal(t, append(first, second...), got)
876+ before := c.usage()
877+ refused := up.mustCreate("too big")
878+ _, err = up.block(refused, "a", make([]byte, domain.CIArtifactMaxBlockSize+1))
879+ require.Error(t, err)
880+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
881+ assert.Contains(t, err.Error(), "a block has at most 8 MiB")
882+ assert.Equal(t, before, c.usage())
883+ a, err := c.forge.store.Artifacts().GetArtifact(c.ctx, refused)
884+ require.NoError(t, err)
885+ assert.Zero(t, a.Charged)
886+ require.NoError(t, blobdisk.New(c.artifactRoot).List(c.ctx, a.BlobKey()+".blocks/", func(ports.BlobInfo) error { t.Error("refused block survived"); return nil }))
887+}
M internal/app/ci_e2e_test.go +2 -0
@@ -28,6 +28,8 @@ type ciHarness struct {
2828 root *clients
2929 wt string
3030 repo string
31+ // artifactRoot is supplied by the artifact test rig until P8 wires blobs.
32+ artifactRoot string
3133 }
3234
3335 const ciWorkflow = `name: ci
M internal/app/cli_e2e_test.go +4 -3
@@ -334,7 +334,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
334334 assert.JSONEq(t, `[]`, res.stdout)
335335
336336 // version: no token needed. This server runs on http://127.0.0.1, where
337- // passkeys are not available: they are the only feature missing.
337+ // passkeys are not available.
338338 res = c.cli(t, "", "version", "--json").ok(t)
339339 assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
340340 assert.Contains(t, res.stderr, "the server does not support passkeys")
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -389,7 +389,8 @@ func TestAC6CLIEndToEnd(t *testing.T) {
389389 assert.Empty(t, tk.Secret, "list never shows secrets")
390390 byLabel[tk.Label] = tk
391391 }
392- assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope")
392+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()))
393+ assert.Contains(t, byLabel["daily"].Scopes, "release:write")
393394 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
394395 assert.NotContains(t, res.stdout, `"secret"`)
395396 res = c.cli(t, secret, "token", "list").ok(t)
A internal/app/cli_release.go +603 -0
@@ -0,0 +1,603 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "errors"
11+ "fmt"
12+ "io"
13+ "mime"
14+ "net/http"
15+ "net/url"
16+ "os"
17+ "path/filepath"
18+ "slices"
19+ "strconv"
20+ "strings"
21+
22+ "connectrpc.com/connect"
23+ "github.com/spf13/cobra"
24+ "golang.org/x/term"
25+ "google.golang.org/protobuf/types/known/fieldmaskpb"
26+
27+ "bnema.dev/ermite-api/gen/ermite"
28+ "bnema.dev/ermite/internal/adapters/client"
29+)
30+
31+var errNoReleases = errors.New("the server has no releases (it lacks the releases feature)")
32+
33+func requireReleases(ctx context.Context, c *client.Client) error {
34+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
35+ if err != nil {
36+ return c.Describe(err)
37+ }
38+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureReleases) {
39+ return errNoReleases
40+ }
41+ return nil
42+}
43+
44+func releaseClient(env *cliEnv, cmd *cobra.Command) (*client.Client, error) {
45+ c, err := env.client(cmd)
46+ if err != nil {
47+ return nil, err
48+ }
49+ if err := requireReleases(env.ctx(cmd), c); err != nil {
50+ return nil, err
51+ }
52+ return c, nil
53+}
54+
55+// jsonRelease and jsonReleaseAsset keep the CLI shape independent of protobuf JSON.
56+type jsonReleaseAsset struct {
57+ ID int64 `json:"id"`
58+ ReleaseID int64 `json:"release_id"`
59+ Name string `json:"name"`
60+ Label string `json:"label"`
61+ ContentType string `json:"content_type"`
62+ Size int64 `json:"size"`
63+ Digest string `json:"digest"`
64+ Uploader string `json:"uploader"`
65+ CreatedAt *string `json:"created_at"`
66+ UpdatedAt *string `json:"updated_at"`
67+}
68+type jsonRelease struct {
69+ ID int64 `json:"id"`
70+ Repo string `json:"repo"`
71+ Tag string `json:"tag"`
72+ Title string `json:"title"`
73+ Notes string `json:"notes"`
74+ Draft bool `json:"draft"`
75+ Prerelease bool `json:"prerelease"`
76+ Latest bool `json:"latest"`
77+ TargetCommitish string `json:"target_commitish"`
78+ Author string `json:"author"`
79+ CreatedAt *string `json:"created_at"`
80+ PublishedAt *string `json:"published_at"`
81+ UpdatedAt *string `json:"updated_at"`
82+ Assets []jsonReleaseAsset `json:"assets"`
83+}
84+
85+func toJSONRelease(r *ermite.Release) jsonRelease {
86+ out := jsonRelease{ID: r.GetId(), Repo: r.GetRepo(), Tag: r.GetTag(), Title: r.GetTitle(), Notes: r.GetNotes(), Draft: r.GetDraft(), Prerelease: r.GetPrerelease(), Latest: r.GetLatest(), TargetCommitish: r.GetTargetCommitish(), Author: r.GetAuthor(), CreatedAt: jsonTime(r.GetCreatedAt()), PublishedAt: jsonTime(r.GetPublishedAt()), UpdatedAt: jsonTime(r.GetUpdatedAt()), Assets: []jsonReleaseAsset{}}
87+ for _, a := range r.GetAssets() {
88+ out.Assets = append(out.Assets, jsonReleaseAsset{a.GetId(), a.GetReleaseId(), a.GetName(), a.GetLabel(), a.GetContentType(), a.GetSize(), a.GetDigest(), a.GetUploader(), jsonTime(a.GetCreatedAt()), jsonTime(a.GetUpdatedAt())})
89+ }
90+ return out
91+}
92+
93+const releaseJSONHelp = "\nJSON fields: id, repo, tag, title, notes, draft, prerelease, latest, target_commitish, author,\ncreated_at, published_at, updated_at (RFC 3339 UTC or null), assets (id, release_id, name, label,\ncontent_type, size, digest, uploader, created_at, updated_at). list prints an array."
94+
95+func newReleaseCmd(env *cliEnv) *cobra.Command {
96+ cmd := &cobra.Command{Use: "release", Short: "Manage releases and their assets", Long: "Create and manage tagged releases. Reads follow repository visibility; writes need release:write.\nNeeds a server with the releases feature. Select releases by tag, numeric id, or latest (view/download)."}
97+ cmd.AddCommand(newReleaseCreateCmd(env), newReleaseListCmd(env), newReleaseViewCmd(env), newReleaseEditCmd(env), newReleaseDeleteCmd(env), newReleaseUploadCmd(env), newReleaseDownloadCmd(env))
98+ return cmd
99+}
100+func releaseCommand(env *cliEnv, use, short string, n int) *cobra.Command {
101+ return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(n), ValidArgsFunction: completeRepoNames(env)}
102+}
103+
104+func getRelease(ctx context.Context, c *client.Client, repo, selector string, latest bool) (*ermite.Release, error) {
105+ req := &ermite.GetReleaseRequest{Repo: repo}
106+ if selector == "latest" && latest {
107+ req.Selector = &ermite.GetReleaseRequest_Latest{Latest: true}
108+ } else if selector != "" && strings.Trim(selector, "0123456789") == "" {
109+ id, err := strconv.ParseInt(selector, 10, 64)
110+ if err != nil || id <= 0 {
111+ return nil, fmt.Errorf("invalid release id %q", selector)
112+ }
113+ req.Selector = &ermite.GetReleaseRequest_Id{Id: id}
114+ } else {
115+ req.Selector = &ermite.GetReleaseRequest_Tag{Tag: selector}
116+ }
117+ res, err := c.Releases().GetRelease(ctx, connect.NewRequest(req))
118+ if err != nil {
119+ return nil, c.Describe(err)
120+ }
121+ return res.Msg.GetRelease(), nil
122+}
123+func releaseState(r *ermite.Release) string {
124+ if r.GetDraft() {
125+ return "draft"
126+ }
127+ if r.GetPrerelease() {
128+ return "prerelease"
129+ }
130+ return "published"
131+}
132+func releaseURL(base, repo, tag string) string {
133+ return strings.TrimRight(base, "/") + "/" + url.PathEscape(repo) + "/releases/tag/" + url.PathEscape(tag)
134+}
135+func outputRelease(cmd *cobra.Command, c *client.Client, r *ermite.Release, asJSON bool) error {
136+ if asJSON {
137+ return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
138+ }
139+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\nid: %d\n", releaseURL(c.URL(), r.GetRepo(), r.GetTag()), r.GetId())
140+ return err
141+}
142+
143+type releaseFields struct {
144+ title, notes, notesFile, target, tag string
145+ draft, publish, prerelease, noPrerelease, latest bool
146+}
147+
148+func (f *releaseFields) flags(cmd *cobra.Command, edit bool) {
149+ cmd.Flags().StringVar(&f.title, "title", "", "release title")
150+ cmd.Flags().StringVar(&f.notes, "notes", "", "Markdown release notes")
151+ cmd.Flags().StringVar(&f.notesFile, "notes-file", "", "read Markdown release notes from a file (- for stdin)")
152+ cmd.MarkFlagsMutuallyExclusive("notes", "notes-file")
153+ cmd.Flags().StringVar(&f.target, "target", "", "target commit or branch for a missing tag")
154+ cmd.Flags().BoolVar(&f.draft, "draft", false, "keep as a draft")
155+ cmd.Flags().BoolVar(&f.prerelease, "prerelease", false, "mark as a prerelease")
156+ cmd.Flags().BoolVar(&f.latest, "latest", false, "pin as latest")
157+ if edit {
158+ cmd.Flags().StringVar(&f.tag, "tag", "", "new tag")
159+ cmd.Flags().BoolVar(&f.publish, "publish", false, "publish a draft")
160+ cmd.Flags().BoolVar(&f.noPrerelease, "no-prerelease", false, "clear the prerelease flag")
161+ cmd.MarkFlagsMutuallyExclusive("draft", "publish")
162+ cmd.MarkFlagsMutuallyExclusive("prerelease", "no-prerelease")
163+ }
164+}
165+func (f *releaseFields) readNotes(cmd *cobra.Command) error {
166+ if !cmd.Flags().Changed("notes-file") {
167+ return nil
168+ }
169+ var data []byte
170+ var err error
171+ if f.notesFile == "-" {
172+ data, err = io.ReadAll(cmd.InOrStdin())
173+ } else {
174+ data, err = os.ReadFile(f.notesFile)
175+ }
176+ f.notes = string(data)
177+ return err
178+}
179+func newReleaseCreateCmd(env *cliEnv) *cobra.Command {
180+ var f releaseFields
181+ var assets []string
182+ cmd := releaseCommand(env, "create <repo> <tag>", "Create a release", 2)
183+ cmd.Long = "Create a release, then upload each --asset FILE[#LABEL] (repeatable; shell expands globs)." + releaseJSONHelp
184+ f.flags(cmd, false)
185+ cmd.Flags().StringArrayVar(&assets, "asset", nil, "upload FILE[#LABEL] (repeatable)")
186+ asJSON := addJSONFlag(cmd)
187+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
188+ if err := f.readNotes(cmd); err != nil {
189+ return err
190+ }
191+ c, err := releaseClient(env, cmd)
192+ if err != nil {
193+ return err
194+ }
195+ ctx := env.ctx(cmd)
196+ res, err := c.Releases().CreateRelease(ctx, connect.NewRequest(&ermite.CreateReleaseRequest{Repo: args[0], Tag: args[1], Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft, Prerelease: f.prerelease, MakeLatest: f.latest}))
197+ if err != nil {
198+ return c.Describe(err)
199+ }
200+ r := res.Msg.GetRelease()
201+ for _, spec := range assets {
202+ a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, "", false)
203+ if err != nil {
204+ return fmt.Errorf("release %d was created, but asset upload failed: %w", r.GetId(), err)
205+ }
206+ r.Assets = append(r.Assets, a)
207+ }
208+ return outputRelease(cmd, c, r, *asJSON)
209+ }
210+ return cmd
211+}
212+func newReleaseListCmd(env *cliEnv) *cobra.Command {
213+ var limit int32
214+ cmd := releaseCommand(env, "list <repo>", "List releases, newest first", 1)
215+ cmd.Long = "List releases, including drafts visible to writers." + releaseJSONHelp
216+ cmd.Flags().Int32Var(&limit, "limit", 30, "maximum number of releases")
217+ asJSON := addJSONFlag(cmd)
218+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
219+ if limit <= 0 {
220+ return errors.New("--limit must be positive")
221+ }
222+ c, err := releaseClient(env, cmd)
223+ if err != nil {
224+ return err
225+ }
226+ all := []*ermite.Release{}
227+ page := ""
228+ for int32(len(all)) < limit {
229+ res, err := c.Releases().ListReleases(env.ctx(cmd), connect.NewRequest(&ermite.ListReleasesRequest{Repo: args[0], PageSize: limit - int32(len(all)), PageToken: page}))
230+ if err != nil {
231+ return c.Describe(err)
232+ }
233+ all = append(all, res.Msg.GetReleases()...)
234+ next := res.Msg.GetNextPageToken()
235+ if next == "" {
236+ break
237+ }
238+ if next == page {
239+ return errors.New("the server repeated a release page token")
240+ }
241+ page = next
242+ }
243+ if int32(len(all)) > limit {
244+ all = all[:limit]
245+ }
246+ if *asJSON {
247+ out := make([]jsonRelease, 0, len(all))
248+ for _, r := range all {
249+ out = append(out, toJSONRelease(r))
250+ }
251+ return writeJSON(cmd.OutOrStdout(), out)
252+ }
253+ rows := [][]string{}
254+ for _, r := range all {
255+ rows = append(rows, []string{strconv.FormatInt(r.GetId(), 10), r.GetTag(), r.GetTitle(), releaseState(r), shortTime(r.GetPublishedAt(), "-"), strconv.Itoa(len(r.GetAssets()))})
256+ }
257+ return table(cmd.OutOrStdout(), []string{"ID", "TAG", "TITLE", "STATE", "PUBLISHED", "ASSETS"}, rows)
258+ }
259+ return cmd
260+}
261+func newReleaseViewCmd(env *cliEnv) *cobra.Command {
262+ var notes bool
263+ cmd := releaseCommand(env, "view <repo> <tag|id|latest>", "View a release and its assets", 2)
264+ cmd.Long = "View release fields, Markdown notes and assets. --notes prints only Markdown." + releaseJSONHelp
265+ asJSON := addJSONFlag(cmd)
266+ cmd.Flags().BoolVar(&notes, "notes", false, "print only Markdown notes")
267+ cmd.MarkFlagsMutuallyExclusive("json", "notes")
268+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
269+ c, err := releaseClient(env, cmd)
270+ if err != nil {
271+ return err
272+ }
273+ r, err := getRelease(env.ctx(cmd), c, args[0], args[1], true)
274+ if err != nil {
275+ return err
276+ }
277+ if *asJSON {
278+ return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
279+ }
280+ if notes {
281+ _, err := fmt.Fprint(cmd.OutOrStdout(), r.GetNotes())
282+ return err
283+ }
284+ if err := fields(cmd.OutOrStdout(), [][2]string{{"id", strconv.FormatInt(r.GetId(), 10)}, {"tag", r.GetTag()}, {"title", r.GetTitle()}, {"state", releaseState(r)}, {"latest", strconv.FormatBool(r.GetLatest())}, {"target", r.GetTargetCommitish()}, {"author", r.GetAuthor()}, {"created", shortTime(r.GetCreatedAt(), "-")}, {"published", shortTime(r.GetPublishedAt(), "-")}, {"updated", shortTime(r.GetUpdatedAt(), "-")}}); err != nil {
285+ return err
286+ }
287+ if _, err := fmt.Fprintln(cmd.OutOrStdout(), clean(r.GetNotes())); err != nil {
288+ return err
289+ }
290+ rows := [][]string{}
291+ for _, a := range r.GetAssets() {
292+ rows = append(rows, []string{a.GetName(), humanBytes(a.GetSize()), a.GetContentType()})
293+ }
294+ return table(cmd.OutOrStdout(), []string{"NAME", "SIZE", "TYPE"}, rows)
295+ }
296+ return cmd
297+}
298+func newReleaseEditCmd(env *cliEnv) *cobra.Command {
299+ var f releaseFields
300+ cmd := releaseCommand(env, "edit <repo> <tag|id>", "Edit a release", 2)
301+ f.flags(cmd, true)
302+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
303+ if err := f.readNotes(cmd); err != nil {
304+ return err
305+ }
306+ paths := []string{}
307+ for _, p := range [][2]string{{"title", "title"}, {"notes", "notes"}, {"notes-file", "notes"}, {"tag", "tag"}, {"target", "target_commitish"}, {"draft", "draft"}, {"publish", "draft"}, {"prerelease", "prerelease"}, {"no-prerelease", "prerelease"}, {"latest", "make_latest"}} {
308+ if cmd.Flags().Changed(p[0]) {
309+ paths = append(paths, p[1])
310+ }
311+ }
312+ if len(paths) == 0 {
313+ return errors.New("give at least one field to edit")
314+ }
315+ c, err := releaseClient(env, cmd)
316+ if err != nil {
317+ return err
318+ }
319+ ctx := env.ctx(cmd)
320+ r, err := getRelease(ctx, c, args[0], args[1], false)
321+ if err != nil {
322+ return err
323+ }
324+ res, err := c.Releases().UpdateRelease(ctx, connect.NewRequest(&ermite.UpdateReleaseRequest{Repo: args[0], Id: r.GetId(), Tag: f.tag, Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft && !f.publish, Prerelease: f.prerelease && !f.noPrerelease, MakeLatest: f.latest, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}))
325+ if err != nil {
326+ return c.Describe(err)
327+ }
328+ return outputRelease(cmd, c, res.Msg.GetRelease(), false)
329+ }
330+ return cmd
331+}
332+func newReleaseDeleteCmd(env *cliEnv) *cobra.Command {
333+ var yes bool
334+ cmd := releaseCommand(env, "delete <repo> <tag|id>", "Delete a release and its assets (keeps the tag)", 2)
335+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation")
336+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
337+ c, err := releaseClient(env, cmd)
338+ if err != nil {
339+ return err
340+ }
341+ ctx := env.ctx(cmd)
342+ r, err := getRelease(ctx, c, args[0], args[1], false)
343+ if err != nil {
344+ return err
345+ }
346+ if in, ok := cmd.InOrStdin().(*os.File); !yes && ok && term.IsTerminal(int(in.Fd())) {
347+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Delete release %q and all its assets? [y/N] ", clean(r.GetTag()))
348+ answer, err := bufio.NewReader(in).ReadString('\n')
349+ if err != nil || !strings.EqualFold(strings.TrimSpace(answer), "y") {
350+ return errors.New("aborted")
351+ }
352+ }
353+ _, err = c.Releases().DeleteRelease(ctx, connect.NewRequest(&ermite.DeleteReleaseRequest{Repo: args[0], Id: r.GetId()}))
354+ if err != nil {
355+ return c.Describe(err)
356+ }
357+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted release %d\n", r.GetId())
358+ return err
359+ }
360+ return cmd
361+}
362+func newReleaseUploadCmd(env *cliEnv) *cobra.Command {
363+ var clobber bool
364+ var contentType string
365+ cmd := releaseCommand(env, "upload <repo> <tag|id> FILE[#LABEL]...", "Upload release assets", 3)
366+ cmd.Args = cobra.MinimumNArgs(3)
367+ cmd.Flags().BoolVar(&clobber, "clobber", false, "replace assets with the same name")
368+ cmd.Flags().StringVar(&contentType, "content-type", "", "override detected media type")
369+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
370+ c, err := releaseClient(env, cmd)
371+ if err != nil {
372+ return err
373+ }
374+ ctx := env.ctx(cmd)
375+ r, err := getRelease(ctx, c, args[0], args[1], false)
376+ if err != nil {
377+ return err
378+ }
379+ for _, spec := range args[2:] {
380+ a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, contentType, clobber)
381+ if err != nil {
382+ return err
383+ }
384+ noticef(cmd, "uploaded %s (%s)", clean(a.GetName()), humanBytes(a.GetSize()))
385+ }
386+ return nil
387+ }
388+ return cmd
389+}
390+func uploadReleaseFile(ctx context.Context, c *client.Client, repo string, id int64, spec, contentType string, replace bool) (*ermite.ReleaseAsset, error) {
391+ filename, label, _ := strings.Cut(spec, "#")
392+ f, err := os.Open(filename)
393+ if err != nil {
394+ return nil, err
395+ }
396+ defer func() { _ = f.Close() }()
397+ info, err := f.Stat()
398+ if err != nil {
399+ return nil, err
400+ }
401+ if !info.Mode().IsRegular() {
402+ return nil, fmt.Errorf("%q is not a regular file", filename)
403+ }
404+ if contentType == "" {
405+ contentType = mime.TypeByExtension(filepath.Ext(filename))
406+ if contentType == "" {
407+ buf := make([]byte, 512)
408+ n, err := f.Read(buf)
409+ if err != nil && !errors.Is(err, io.EOF) {
410+ return nil, err
411+ }
412+ contentType = http.DetectContentType(buf[:n])
413+ if _, err := f.Seek(0, io.SeekStart); err != nil {
414+ return nil, err
415+ }
416+ }
417+ }
418+ if _, _, err := mime.ParseMediaType(contentType); err != nil {
419+ return nil, fmt.Errorf("invalid --content-type: %w", err)
420+ }
421+ name := filepath.Base(filename)
422+ stream := c.Releases().UploadReleaseAsset(ctx)
423+ // Always close the stream, including a local read or Send failure.
424+ defer func() {
425+ if conn, err := stream.Conn(); err == nil {
426+ _ = conn.CloseRequest()
427+ _ = conn.CloseResponse()
428+ }
429+ }()
430+ err = stream.Send(&ermite.UploadReleaseAssetRequest{Repo: repo, ReleaseId: id, Name: name, Label: label, ContentType: contentType, Size: info.Size(), Replace: replace})
431+ buf := make([]byte, 1<<20)
432+ for err == nil {
433+ n, rerr := f.Read(buf)
434+ if n > 0 {
435+ err = stream.Send(&ermite.UploadReleaseAssetRequest{Data: buf[:n]})
436+ }
437+ if rerr != nil {
438+ if !errors.Is(rerr, io.EOF) {
439+ return nil, rerr
440+ }
441+ break
442+ }
443+ }
444+ // CloseAndReceive exposes a server error even when Send returned EOF.
445+ res, closeErr := stream.CloseAndReceive()
446+ if closeErr != nil {
447+ err = closeErr
448+ }
449+ if err != nil {
450+ if connect.CodeOf(err) == connect.CodeAlreadyExists && !replace {
451+ return nil, fmt.Errorf("asset %q already exists; use --clobber to replace it", clean(name))
452+ }
453+ return nil, c.Describe(err)
454+ }
455+ return res.Msg.GetAsset(), nil
456+}
457+func newReleaseDownloadCmd(env *cliEnv) *cobra.Command {
458+ var names, patterns []string
459+ var dir string
460+ cmd := releaseCommand(env, "download <repo> <tag|id|latest>", "Download assets without overwriting existing files", 2)
461+ cmd.Flags().StringArrayVar(&names, "name", nil, "asset name (repeatable)")
462+ cmd.Flags().StringArrayVar(&patterns, "pattern", nil, "asset name glob (repeatable)")
463+ cmd.Flags().StringVarP(&dir, "dir", "D", ".", "download directory")
464+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
465+ for _, p := range patterns {
466+ if _, err := filepath.Match(p, ""); err != nil {
467+ return fmt.Errorf("--pattern: %w", err)
468+ }
469+ }
470+ c, err := releaseClient(env, cmd)
471+ if err != nil {
472+ return err
473+ }
474+ ctx := env.ctx(cmd)
475+ r, err := getRelease(ctx, c, args[0], args[1], true)
476+ if err != nil {
477+ return err
478+ }
479+ picked, err := pickReleaseAssets(r.GetAssets(), names, patterns)
480+ if err != nil {
481+ return err
482+ }
483+ if len(picked) == 0 {
484+ return errors.New("no matching release assets")
485+ }
486+ for _, a := range picked {
487+ if err := checkArtifactName(a.GetName()); err != nil {
488+ return err
489+ }
490+ }
491+ if err := os.MkdirAll(dir, 0o755); err != nil {
492+ return err
493+ }
494+ root, err := os.OpenRoot(dir)
495+ if err != nil {
496+ return err
497+ }
498+ defer func() { _ = root.Close() }()
499+ for _, a := range picked {
500+ if err := downloadReleaseAsset(ctx, c, args[0], a, root); err != nil {
501+ return fmt.Errorf("asset %q: %w", clean(a.GetName()), err)
502+ }
503+ noticef(cmd, "downloaded %s to %s", clean(a.GetName()), clean(filepath.Join(dir, a.GetName())))
504+ }
505+ return nil
506+ }
507+ return cmd
508+}
509+func pickReleaseAssets(all []*ermite.ReleaseAsset, names, patterns []string) ([]*ermite.ReleaseAsset, error) {
510+ for _, name := range names {
511+ if !slices.ContainsFunc(all, func(a *ermite.ReleaseAsset) bool { return a.GetName() == name }) {
512+ return nil, fmt.Errorf("the release has no asset %q", clean(name))
513+ }
514+ }
515+ out := []*ermite.ReleaseAsset{}
516+ for _, a := range all {
517+ match := len(names) == 0 && len(patterns) == 0 || slices.Contains(names, a.GetName())
518+ for _, p := range patterns {
519+ ok, err := filepath.Match(p, a.GetName())
520+ if err != nil {
521+ return nil, err
522+ }
523+ match = match || ok
524+ }
525+ if match {
526+ out = append(out, a)
527+ }
528+ }
529+ return out, nil
530+}
531+
532+// downloadReleaseAsset owns the destination exclusively; errors remove it. Only
533+// transport failures resume, once, using the immutable asset id and kept offset.
534+func downloadReleaseAsset(ctx context.Context, c *client.Client, repo string, a *ermite.ReleaseAsset, root *os.Root) (err error) {
535+ f, err := root.OpenFile(a.GetName(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
536+ if err != nil {
537+ return err
538+ }
539+ defer func() {
540+ cerr := f.Close()
541+ if err == nil {
542+ err = cerr
543+ }
544+ if err != nil {
545+ _ = root.Remove(a.GetName())
546+ }
547+ }()
548+ hash := sha256.New()
549+ w := io.MultiWriter(f, hash)
550+ var kept int64
551+ for attempt := 0; attempt < 2; attempt++ {
552+ stream, openErr := c.Releases().DownloadReleaseAsset(ctx, connect.NewRequest(&ermite.DownloadReleaseAssetRequest{Repo: repo, Selector: &ermite.DownloadReleaseAssetRequest_AssetId{AssetId: a.GetId()}, Offset: kept}))
553+ if openErr != nil {
554+ err = openErr
555+ } else {
556+ described := false
557+ for stream.Receive() {
558+ msg := stream.Msg()
559+ if !described {
560+ meta := msg.GetAsset()
561+ if meta == nil || meta.GetId() != a.GetId() || meta.GetSize() != a.GetSize() || meta.GetDigest() != a.GetDigest() {
562+ _ = stream.Close()
563+ return errors.New("the server did not describe the expected asset")
564+ }
565+ described = true
566+ }
567+ chunk := msg.GetChunk()
568+ if kept+int64(len(chunk)) > a.GetSize() {
569+ _ = stream.Close()
570+ return errors.New("the server sent more than the asset size")
571+ }
572+ n, werr := w.Write(chunk)
573+ kept += int64(n)
574+ if werr != nil {
575+ _ = stream.Close()
576+ return werr
577+ }
578+ }
579+ err = stream.Err()
580+ _ = stream.Close()
581+ if err == nil && (!described || kept != a.GetSize()) {
582+ err = io.ErrUnexpectedEOF
583+ }
584+ }
585+ if err == nil {
586+ break
587+ }
588+ if ctx.Err() != nil {
589+ return ctx.Err()
590+ }
591+ code := connect.CodeOf(err)
592+ if attempt == 1 || (code != connect.CodeUnavailable && code != connect.CodeInternal && !errors.Is(err, io.ErrUnexpectedEOF)) {
593+ return c.Describe(err)
594+ }
595+ }
596+ if err != nil {
597+ return c.Describe(err)
598+ }
599+ if a.GetDigest() != "sha256:"+hex.EncodeToString(hash.Sum(nil)) {
600+ return errors.New("asset digest mismatch")
601+ }
602+ return f.Sync()
603+}
A internal/app/cli_release_e2e_test.go +65 -0
@@ -0,0 +1,65 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/rpc"
17+)
18+
19+func TestReleaseCLIEndToEnd(t *testing.T) {
20+ c := newCLIHarness(t)
21+ c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
22+ wt := c.workTree("main", "README.md")
23+ c.mustGit(wt, "tag", "v1")
24+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
25+
26+ bin := func(args ...string) string {
27+ t.Helper()
28+ out, stderr, code := c.bin(t, adminToken, args...)
29+ require.Equal(t, 0, code, stderr)
30+ return out
31+ }
32+ asset := filepath.Join(t.TempDir(), "binary.dat")
33+ data := []byte("release payload\x00\xff")
34+ require.NoError(t, os.WriteFile(asset, data, 0o600))
35+ created := decode[jsonRelease](t, bin("release", "create", "proj", "v1", "--title", "Version one", "--notes", "# shipped", "--asset", asset+"#Binary", "--json"))
36+ require.Positive(t, created.ID)
37+ require.Len(t, created.Assets, 1)
38+ assert.Equal(t, "Binary", created.Assets[0].Label)
39+ viewed := decode[jsonRelease](t, bin("release", "view", "proj", "v1", "--json"))
40+ assert.Equal(t, created.ID, viewed.ID)
41+ assert.Equal(t, "# shipped", viewed.Notes)
42+ assert.Contains(t, bin("release", "list", "proj"), "Version one")
43+ bin("release", "edit", "proj", strconv.FormatInt(created.ID, 10), "--title", "Edited", "--no-prerelease")
44+ assert.Equal(t, "Edited", decode[jsonRelease](t, bin("release", "view", "proj", "latest", "--json")).Title)
45+ dir := t.TempDir()
46+ bin("release", "download", "proj", "latest", "--dir", dir, "--pattern", "*.dat")
47+ downloaded, err := os.ReadFile(filepath.Join(dir, "binary.dat"))
48+ require.NoError(t, err)
49+ assert.Equal(t, data, downloaded)
50+ _, stderr, code := c.bin(t, adminToken, "release", "upload", "proj", "v1", asset)
51+ requireOneLineError(t, stderr, code, "binary.dat", "--clobber")
52+ bin("release", "upload", "proj", "v1", asset, "--clobber")
53+ bin("release", "delete", "proj", "v1", "--yes")
54+ _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
55+ requireOneLineError(t, stderr, code, "not found")
56+ // An older server without the service still gets a clear refusal.
57+ api := rpc.New(rpc.Config{Auth: c.forge.Auth, SigningKey: c.forge.SigningKey, Sessions: c.forge.Sessions})
58+ mux := http.NewServeMux()
59+ mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
60+ srv := httptest.NewServer(mux)
61+ t.Cleanup(srv.Close)
62+ c.url = srv.URL
63+ _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
64+ requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
65+}
A internal/app/cli_release_test.go +388 -0
@@ -0,0 +1,388 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "fmt"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "sync"
14+ "testing"
15+
16+ "connectrpc.com/connect"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+ "google.golang.org/protobuf/proto"
20+
21+ "bnema.dev/ermite-api/gen/ermite"
22+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23+)
24+
25+// A focused version of cli_fake_test's Connect rig, without changing the shared fake.
26+type fakeReleaseAPI struct {
27+ ermiteconnect.UnimplementedReleaseServiceHandler
28+ mu sync.Mutex
29+ create *ermite.CreateReleaseRequest
30+ gets []*ermite.GetReleaseRequest
31+ lists []*ermite.ListReleasesRequest
32+ update *ermite.UpdateReleaseRequest
33+ deleted *ermite.DeleteReleaseRequest
34+ uploads []*ermite.UploadReleaseAssetRequest
35+ offsets []int64
36+ release *ermite.Release
37+ data []byte
38+ uploadErr error
39+ download func(*ermite.DownloadReleaseAssetRequest, *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error
40+}
41+
42+func (f *fakeReleaseAPI) serve(t *testing.T, features ...string) string {
43+ t.Helper()
44+ mux := http.NewServeMux()
45+ p, h := ermiteconnect.NewReleaseServiceHandler(f)
46+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
47+ v := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
48+ return &ermite.GetVersionResponse{Features: features}, nil
49+ }}
50+ p, h = ermiteconnect.NewServerServiceHandler(v)
51+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
52+ srv := httptest.NewServer(mux)
53+ t.Cleanup(srv.Close)
54+ return srv.URL
55+}
56+func (f *fakeReleaseAPI) CreateRelease(_ context.Context, r *connect.Request[ermite.CreateReleaseRequest]) (*connect.Response[ermite.CreateReleaseResponse], error) {
57+ f.mu.Lock()
58+ defer f.mu.Unlock()
59+ f.create = proto.Clone(r.Msg).(*ermite.CreateReleaseRequest)
60+ return connect.NewResponse(&ermite.CreateReleaseResponse{Release: f.release}), nil
61+}
62+func (f *fakeReleaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
63+ f.mu.Lock()
64+ defer f.mu.Unlock()
65+ f.gets = append(f.gets, proto.Clone(r.Msg).(*ermite.GetReleaseRequest))
66+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: f.release}), nil
67+}
68+func (f *fakeReleaseAPI) ListReleases(_ context.Context, r *connect.Request[ermite.ListReleasesRequest]) (*connect.Response[ermite.ListReleasesResponse], error) {
69+ f.mu.Lock()
70+ defer f.mu.Unlock()
71+ f.lists = append(f.lists, proto.Clone(r.Msg).(*ermite.ListReleasesRequest))
72+ next := ""
73+ if r.Msg.GetPageToken() == "" {
74+ next = "next"
75+ }
76+ return connect.NewResponse(&ermite.ListReleasesResponse{Releases: []*ermite.Release{f.release}, NextPageToken: next}), nil
77+}
78+func (f *fakeReleaseAPI) UpdateRelease(_ context.Context, r *connect.Request[ermite.UpdateReleaseRequest]) (*connect.Response[ermite.UpdateReleaseResponse], error) {
79+ f.mu.Lock()
80+ defer f.mu.Unlock()
81+ f.update = proto.Clone(r.Msg).(*ermite.UpdateReleaseRequest)
82+ return connect.NewResponse(&ermite.UpdateReleaseResponse{Release: f.release}), nil
83+}
84+func (f *fakeReleaseAPI) DeleteRelease(_ context.Context, r *connect.Request[ermite.DeleteReleaseRequest]) (*connect.Response[ermite.DeleteReleaseResponse], error) {
85+ f.mu.Lock()
86+ defer f.mu.Unlock()
87+ f.deleted = proto.Clone(r.Msg).(*ermite.DeleteReleaseRequest)
88+ return connect.NewResponse(&ermite.DeleteReleaseResponse{}), nil
89+}
90+func (f *fakeReleaseAPI) UploadReleaseAsset(_ context.Context, st *connect.ClientStream[ermite.UploadReleaseAssetRequest]) (*connect.Response[ermite.UploadReleaseAssetResponse], error) {
91+ var got []*ermite.UploadReleaseAssetRequest
92+ for st.Receive() {
93+ got = append(got, proto.Clone(st.Msg()).(*ermite.UploadReleaseAssetRequest))
94+ }
95+ if err := st.Err(); err != nil {
96+ return nil, err
97+ }
98+ f.mu.Lock()
99+ defer f.mu.Unlock()
100+ f.uploads = append(f.uploads, got...)
101+ if f.uploadErr != nil {
102+ return nil, f.uploadErr
103+ }
104+ return connect.NewResponse(&ermite.UploadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: got[0].GetName(), Size: got[0].GetSize()}}), nil
105+}
106+func (f *fakeReleaseAPI) DownloadReleaseAsset(_ context.Context, r *connect.Request[ermite.DownloadReleaseAssetRequest], st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
107+ f.mu.Lock()
108+ f.offsets = append(f.offsets, r.Msg.GetOffset())
109+ fn := f.download
110+ f.mu.Unlock()
111+ if fn != nil {
112+ return fn(r.Msg, st)
113+ }
114+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: f.release.Assets[0]}); err != nil {
115+ return err
116+ }
117+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: f.data[r.Msg.GetOffset():]})
118+}
119+func releaseFixture() *ermite.Release {
120+ return &ermite.Release{Id: 7, Repo: "proj", Tag: "v1", Title: "First", Notes: "# Notes\n", PublishedAt: ts("2026-10-10T12:00:00Z"), Assets: []*ermite.ReleaseAsset{}}
121+}
122+
123+func TestCLIReleaseCreateAndUpload(t *testing.T) {
124+ data := make([]byte, 5<<19)
125+ file := filepath.Join(t.TempDir(), "asset.bin")
126+ require.NoError(t, os.WriteFile(file, data, 0o600))
127+ notes := filepath.Join(t.TempDir(), "notes.md")
128+ require.NoError(t, os.WriteFile(notes, []byte("## hello"), 0o600))
129+ f := &fakeReleaseAPI{release: releaseFixture()}
130+ vars := envFor(f.serve(t, "releases"))
131+ res := runCLI(t, vars, "", "release", "create", "proj", "v1", "--title", "Hello", "--notes-file", notes, "--target", "main", "--draft", "--prerelease", "--latest", "--asset", file+"#Binary", "--json").ok(t)
132+ out := decode[jsonRelease](t, res.stdout)
133+ assert.Equal(t, int64(7), out.ID)
134+ require.Len(t, out.Assets, 1)
135+ f.mu.Lock()
136+ defer f.mu.Unlock()
137+ assert.True(t, proto.Equal(&ermite.CreateReleaseRequest{Repo: "proj", Tag: "v1", Title: "Hello", Notes: "## hello", TargetCommitish: "main", Draft: true, Prerelease: true, MakeLatest: true}, f.create))
138+ require.Len(t, f.uploads, 4)
139+ assert.Equal(t, "Binary", f.uploads[0].GetLabel())
140+ assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
141+ assert.Empty(t, f.uploads[0].GetData())
142+ assert.False(t, f.uploads[0].GetReplace())
143+ assert.NotEmpty(t, f.uploads[0].GetContentType())
144+ for i, n := range []int{1 << 20, 1 << 20, 1 << 19} {
145+ assert.Len(t, f.uploads[i+1].GetData(), n)
146+ assert.Empty(t, f.uploads[i+1].GetRepo())
147+ assert.Empty(t, f.uploads[i+1].GetName())
148+ }
149+}
150+func TestCLIReleaseUpload(t *testing.T) {
151+ file := filepath.Join(t.TempDir(), "x")
152+ require.NoError(t, os.WriteFile(file, []byte("hello"), 0o600))
153+ f := &fakeReleaseAPI{release: releaseFixture()}
154+ vars := envFor(f.serve(t, "releases"))
155+ runCLI(t, vars, "", "release", "upload", "proj", "7", file+"#A", "--clobber", "--content-type", "application/test").ok(t)
156+ f.mu.Lock()
157+ assert.True(t, f.uploads[0].GetReplace())
158+ assert.Equal(t, "application/test", f.uploads[0].GetContentType())
159+ assert.Equal(t, int64(7), f.gets[0].GetId())
160+ f.uploadErr = connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("taken"))
161+ f.mu.Unlock()
162+ res := runCLI(t, vars, "", "release", "upload", "proj", "v1", file)
163+ require.ErrorContains(t, res.err, "--clobber")
164+ assert.Contains(t, res.err.Error(), `"x"`)
165+}
166+func TestCLIReleaseList(t *testing.T) {
167+ f := &fakeReleaseAPI{release: releaseFixture()}
168+ vars := envFor(f.serve(t, "releases"))
169+ res := runCLI(t, vars, "", "release", "list", "proj", "--limit", "1").ok(t)
170+ assert.Equal(t, "ID TAG TITLE STATE PUBLISHED ASSETS\n7 v1 First published 2026-10-10 12:00 0\n", res.stdout)
171+ res = runCLI(t, vars, "", "release", "list", "proj", "--limit", "2", "--json").ok(t)
172+ assert.Len(t, decode[[]jsonRelease](t, res.stdout), 2)
173+ f.mu.Lock()
174+ defer f.mu.Unlock()
175+ require.Len(t, f.lists, 3)
176+ assert.Equal(t, int32(2), f.lists[1].GetPageSize())
177+ assert.Equal(t, "next", f.lists[2].GetPageToken())
178+ assert.Equal(t, int32(1), f.lists[2].GetPageSize())
179+}
180+func TestCLIReleaseViewSelectors(t *testing.T) {
181+ f := &fakeReleaseAPI{release: releaseFixture()}
182+ vars := envFor(f.serve(t, "releases"))
183+ for _, sel := range []string{"v1", "7", "latest"} {
184+ runCLI(t, vars, "", "release", "view", "proj", sel, "--json").ok(t)
185+ }
186+ assert.Equal(t, "# Notes\n", runCLI(t, vars, "", "release", "view", "proj", "latest", "--notes").ok(t).stdout)
187+ res := runCLI(t, vars, "", "release", "view", "proj", "v1").ok(t)
188+ assert.Contains(t, res.stdout, "NAME SIZE TYPE")
189+ assert.Contains(t, res.stdout, "First")
190+ f.mu.Lock()
191+ defer f.mu.Unlock()
192+ assert.Equal(t, "v1", f.gets[0].GetTag())
193+ assert.Equal(t, int64(7), f.gets[1].GetId())
194+ assert.True(t, f.gets[2].GetLatest())
195+}
196+func TestCLIReleaseEdit(t *testing.T) {
197+ f := &fakeReleaseAPI{release: releaseFixture()}
198+ vars := envFor(f.serve(t, "releases"))
199+ runCLI(t, vars, "", "release", "edit", "proj", "v1", "--title=", "--notes-file", "-", "--tag", "v2", "--target", "main", "--publish", "--no-prerelease", "--latest=false").ok(t)
200+ f.mu.Lock()
201+ assert.ElementsMatch(t, []string{"title", "notes", "tag", "target_commitish", "draft", "prerelease", "make_latest"}, f.update.GetUpdateMask().GetPaths())
202+ assert.False(t, f.update.GetDraft())
203+ assert.False(t, f.update.GetPrerelease())
204+ assert.False(t, f.update.GetMakeLatest())
205+ assert.Empty(t, f.update.GetTitle())
206+ assert.Equal(t, "v2", f.update.GetTag())
207+ f.mu.Unlock()
208+ runCLI(t, vars, "", "release", "edit", "proj", "7", "--draft", "--prerelease", "--notes", "text").ok(t)
209+ f.mu.Lock()
210+ defer f.mu.Unlock()
211+ assert.True(t, f.update.GetDraft())
212+ assert.True(t, f.update.GetPrerelease())
213+ assert.Equal(t, "text", f.update.GetNotes())
214+}
215+func TestCLIReleaseDelete(t *testing.T) {
216+ for _, flags := range [][]string{nil, {"--yes"}} {
217+ f := &fakeReleaseAPI{release: releaseFixture()}
218+ vars := envFor(f.serve(t, "releases"))
219+ res := runCLI(t, vars, "", append([]string{"release", "delete", "proj", "v1"}, flags...)...).ok(t)
220+ assert.Equal(t, "deleted release 7\n", res.stdout)
221+ f.mu.Lock()
222+ assert.Equal(t, int64(7), f.deleted.GetId())
223+ assert.Equal(t, "proj", f.deleted.GetRepo())
224+ f.mu.Unlock()
225+ }
226+}
227+func TestCLIReleaseNoFeature(t *testing.T) {
228+ f := &fakeReleaseAPI{release: releaseFixture()}
229+ vars := envFor(f.serve(t))
230+ for _, args := range [][]string{{"create", "proj", "v1"}, {"list", "proj"}, {"view", "proj", "latest"}, {"edit", "proj", "v1", "--title", "x"}, {"delete", "proj", "v1"}, {"upload", "proj", "v1", "x"}, {"download", "proj", "latest"}} {
231+ res := runCLI(t, vars, "", append([]string{"release"}, args...)...)
232+ require.ErrorIs(t, res.err, errNoReleases)
233+ }
234+ f.mu.Lock()
235+ defer f.mu.Unlock()
236+ assert.Nil(t, f.create)
237+ assert.Empty(t, f.gets)
238+ assert.Nil(t, f.update)
239+ assert.Nil(t, f.deleted)
240+ assert.Empty(t, f.uploads)
241+}
242+func TestCLIReleaseFlagValidation(t *testing.T) {
243+ for _, args := range [][]string{{"create", "proj", "v1", "--notes", "x", "--notes-file", "f"}, {"edit", "proj", "v1", "--draft", "--publish"}, {"edit", "proj", "v1", "--prerelease", "--no-prerelease"}, {"edit", "proj", "v1"}, {"list", "proj", "--limit", "0"}, {"download", "proj", "v1", "--pattern", "["}, {"view", "proj", "v1", "--notes", "--json"}} {
244+ require.Error(t, runCLI(t, nil, "", append([]string{"release"}, args...)...).err)
245+ }
246+}
247+func TestCLIReleaseDownload(t *testing.T) {
248+ for _, mode := range []string{"normal", "resume", "twice broken", "digest mismatch", "unsafe", "overwrite", "empty"} {
249+ t.Run(mode, func(t *testing.T) {
250+ data := []byte("hello world")
251+ if mode == "empty" {
252+ data = nil
253+ }
254+ a := &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: "asset.bin", Size: int64(len(data)), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
255+ if mode == "unsafe" {
256+ a.Name = "../escape"
257+ }
258+ if mode == "digest mismatch" {
259+ a.Digest = "sha256:bad"
260+ }
261+ r := releaseFixture()
262+ r.Assets = []*ermite.ReleaseAsset{a}
263+ f := &fakeReleaseAPI{release: r, data: data}
264+ if mode == "resume" || mode == "twice broken" {
265+ f.download = func(req *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
266+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
267+ return err
268+ }
269+ if req.GetOffset() == 0 {
270+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[:4]}); err != nil {
271+ return err
272+ }
273+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
274+ }
275+ if mode == "twice broken" {
276+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
277+ }
278+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[req.GetOffset():]})
279+ }
280+ }
281+ dir := t.TempDir()
282+ path := filepath.Join(dir, a.GetName())
283+ if mode == "overwrite" {
284+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
285+ }
286+ res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "latest", "--dir", dir)
287+ switch mode {
288+ case "unsafe", "twice broken", "digest mismatch":
289+ require.Error(t, res.err)
290+ if mode != "unsafe" {
291+ _, err := os.Stat(path)
292+ assert.True(t, os.IsNotExist(err))
293+ }
294+ case "overwrite":
295+ require.Error(t, res.err)
296+ got, err := os.ReadFile(path)
297+ require.NoError(t, err)
298+ assert.Equal(t, "keep", string(got))
299+ default:
300+ res.ok(t)
301+ got, err := os.ReadFile(path)
302+ require.NoError(t, err)
303+ assert.Equal(t, string(data), string(got))
304+ }
305+ f.mu.Lock()
306+ defer f.mu.Unlock()
307+ if mode == "resume" || mode == "twice broken" {
308+ assert.Equal(t, []int64{0, 4}, f.offsets)
309+ }
310+ if mode == "normal" {
311+ assert.True(t, f.gets[0].GetLatest())
312+ }
313+ })
314+ }
315+}
316+func TestReleaseAssetSelection(t *testing.T) {
317+ all := []*ermite.ReleaseAsset{{Name: "a.zip"}, {Name: "b.txt"}, {Name: "c.zip"}}
318+ picked, err := pickReleaseAssets(all, []string{"b.txt"}, []string{"*.zip"})
319+ require.NoError(t, err)
320+ assert.Len(t, picked, 3)
321+ picked, err = pickReleaseAssets(all, nil, []string{"*.zip"})
322+ require.NoError(t, err)
323+ assert.Len(t, picked, 2)
324+ _, err = pickReleaseAssets(all, []string{"missing"}, nil)
325+ require.Error(t, err)
326+}
327+
328+func TestCLIReleaseDownloadProtocolFailures(t *testing.T) {
329+ for _, mode := range []string{"missing metadata", "wrong metadata", "too much data", "short data", "permission denied"} {
330+ t.Run(mode, func(t *testing.T) {
331+ data := []byte("abc")
332+ a := &ermite.ReleaseAsset{Id: 9, Name: "a", Size: 3, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
333+ r := releaseFixture()
334+ r.Assets = []*ermite.ReleaseAsset{a}
335+ f := &fakeReleaseAPI{release: r, download: func(_ *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
336+ switch mode {
337+ case "missing metadata":
338+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data})
339+ case "wrong metadata":
340+ return st.Send(&ermite.DownloadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 10}})
341+ case "permission denied":
342+ return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("denied"))
343+ }
344+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
345+ return err
346+ }
347+ if mode == "too much data" {
348+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("abcd")})
349+ }
350+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("a")})
351+ }}
352+ dir := t.TempDir()
353+ res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "v1", "--dir", dir)
354+ require.Error(t, res.err)
355+ _, err := os.Stat(filepath.Join(dir, "a"))
356+ assert.True(t, os.IsNotExist(err))
357+ f.mu.Lock()
358+ defer f.mu.Unlock()
359+ if mode == "short data" {
360+ assert.Len(t, f.offsets, 2)
361+ } else {
362+ assert.Len(t, f.offsets, 1)
363+ }
364+ })
365+ }
366+}
367+
368+func TestCLIReleaseUploadContentTypeAndEmpty(t *testing.T) {
369+ for _, data := range []string{"hello world", ""} {
370+ t.Run(fmt.Sprintf("size %d", len(data)), func(t *testing.T) {
371+ file := filepath.Join(t.TempDir(), "unknown-extension")
372+ require.NoError(t, os.WriteFile(file, []byte(data), 0o600))
373+ f := &fakeReleaseAPI{release: releaseFixture()}
374+ vars := envFor(f.serve(t, "releases"))
375+ runCLI(t, vars, "", "release", "upload", "proj", "v1", file).ok(t)
376+ f.mu.Lock()
377+ defer f.mu.Unlock()
378+ assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
379+ assert.Equal(t, "text/plain; charset=utf-8", f.uploads[0].GetContentType())
380+ if data == "" {
381+ assert.Len(t, f.uploads, 1)
382+ } else {
383+ require.Len(t, f.uploads, 2)
384+ assert.Equal(t, data, string(f.uploads[1].GetData()))
385+ }
386+ })
387+ }
388+}
M internal/app/cli_token.go +9 -0
@@ -145,6 +145,15 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
145145 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146146 }
147147 }
148+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
149+ if err := requireReleases(env.ctx(cmd), c); err != nil {
150+ if !errors.Is(err, errNoReleases) || explicitScope(scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
151+ return fmt.Errorf("%w (needed for the release:write scope; nothing was created)", err)
152+ }
153+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_RELEASE_WRITE })
154+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no releases feature, so the token has no release:write scope")
155+ }
156+ }
148157 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
149158 if err != nil {
150159 return c.Describe(err)
M internal/app/cli_token_test.go +40 -1
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "errors"
7+ "fmt"
78 "strings"
89 "testing"
910 "time"
@@ -174,7 +175,9 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174175 "cli": client.AllScopes(),
175176 } {
176177 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
179+ return &ermite.GetVersionResponse{Features: []string{"issues", "releases"}}, nil
180+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178181 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179182 }}
180183 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
@@ -273,3 +276,39 @@ func TestTokenCompletion(t *testing.T) {
273276 res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
274277 assert.Contains(t, res.stdout, "alpha")
275278 }
279+
280+func TestTokenReleaseWriteFeatureGuard(t *testing.T) {
281+ for _, supported := range []bool{false, true} {
282+ t.Run(fmt.Sprint(supported), func(t *testing.T) {
283+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
284+ features := []string{"issues"}
285+ if supported {
286+ features = append(features, "releases")
287+ }
288+ return &ermite.GetVersionResponse{Features: features}, nil
289+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
290+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
291+ }}
292+ vars := envFor(f.serve(t))
293+ res := runCLI(t, vars, "", "token", "create", "daily", "--preset", "cli").ok(t)
294+ got := lastCreateToken(t, f).GetScopes()
295+ if supported {
296+ assert.Contains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
297+ assert.NotContains(t, res.stderr, "no release:write")
298+ } else {
299+ assert.NotContains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
300+ assert.Contains(t, res.stderr, "the server has no releases feature, so the token has no release:write scope")
301+ }
302+ before, _ := f.snapshot()
303+ res = runCLI(t, vars, "", "token", "create", "explicit", "--scope", "release:write")
304+ if supported {
305+ res.ok(t)
306+ } else {
307+ require.ErrorIs(t, res.err, errNoReleases)
308+ after, _ := f.snapshot()
309+ assert.Equal(t, len(before)+1, len(after))
310+ assert.Equal(t, "GetVersion", after[len(after)-1])
311+ }
312+ })
313+ }
314+}
M internal/app/cli_version.go +1 -0
@@ -109,6 +109,7 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
109109
110110 // featureUse says what breaks without a feature, for the version warning.
111111 var featureUse = map[string]string{
112+ client.FeatureReleases: "`ermite release` needs: the commands will fail on this server, and explicit release:write scopes are refused",
112113 client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113114 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114115 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, releases, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/forge.go +49 -4
@@ -17,6 +17,8 @@ import (
1717
1818 "github.com/bnema/zerowrap"
1919
20+ "bnema.dev/ermite/internal/adapters/blobdisk"
21+ "bnema.dev/ermite/internal/adapters/blobs3"
2022 "bnema.dev/ermite/internal/adapters/ciartifacts"
2123 "bnema.dev/ermite/internal/adapters/cilogs"
2224 "bnema.dev/ermite/internal/adapters/cisummaries"
@@ -153,7 +155,9 @@ type forge struct {
153155 EventLog *usecase.Events
154156 // CI is the CI engine: workflow trigger, planner, runner protocol and the
155157 // owner side. It starts with the server and stops with it.
156- CI *usecase.CI
158+ CI *usecase.CI
159+ Releases *usecase.Releases
160+ Blobs ports.BlobStore
157161 // Settings are the secrets, variables and environments of CI.
158162 Settings *usecase.CISettings
159163 // Mirrors exposes Warning and Badge for the hook server and web composition.
@@ -268,6 +272,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
268272 }
269273 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
270274 f.Browse = usecase.NewBrowse(store, reader)
275+ f.Blobs, err = blobStore(ctx, cfg)
276+ if err != nil {
277+ return nil, err
278+ }
279+ f.Releases = usecase.NewReleases(store.Releases(), f.Blobs, store, reader, git, f.Pushes, f.Bus, clock, ports.ReleaseLimits{AssetMax: cfg.ReleaseAssetMax, AssetsMax: int64(cfg.ReleaseAssetsMax), Quota: cfg.ReleaseQuota})
280+ f.Pushes.WithReleases(f.Releases)
281+ f.Repos.WithReleases(f.Releases)
271282
272283 // Secrets, variables and environments (ADR 0019, CI-3). With a key the
273284 // secrets are sealed under it, and the server refuses to start when the
@@ -335,6 +346,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
335346 OIDC: f.OIDC,
336347 }
337348 artifacts(&ciCfg, cfg, store)
349+ ciCfg.Blobs = f.Blobs
350+ ciCfg.ReleaseSweeper = f.Releases
338351 f.pullRequests(&ciCfg, store, git, committer)
339352 if opts.ci != nil {
340353 opts.ci(&ciCfg)
@@ -459,6 +472,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
459472 OIDCKeys: f.OIDC,
460473 JobArtifacts: jobArtifacts,
461474 Artifacts: artifacts,
475+ Releases: f.Releases,
462476 Version: serverVersion(),
463477 TrustedProxies: cfg.TrustedProxies,
464478 Limiter: limiter,
@@ -487,7 +501,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
487501 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
488502 }
489503 // The REST subset the actions call on github.api_url.
490- api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
504+ releaseAPI := usecase.NewReleasesGitHub(f.Releases, cfg.Owner, cfg.PublicURL)
505+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, Releases: releaseAPI, ReleaseDownload: f.Releases, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
491506 for _, p := range githubapi.Patterns {
492507 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
493508 }
@@ -593,16 +608,46 @@ func (f *forge) Close(ctx context.Context) {
593608 }
594609
595610 // artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):
596-// records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
611+// records in the kv store, legacy contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
597612 // gives none, and the API then serves no artifact service.
598613 func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
599614 if !cfg.CIArtifacts {
600615 return
601616 }
602- ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
617+ ci.Artifacts = store.Artifacts()
618+ if info, err := os.Stat(ciartifacts.Dir(cfg.DataDir)); err == nil && info.IsDir() {
619+ ci.ArtifactFiles = ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
620+ }
603621 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
604622 }
605623
624+// blobStore selects and probes the content backend before any engine starts.
625+func blobStore(ctx context.Context, cfg config.Config) (ports.BlobStore, error) {
626+ var blobs ports.BlobStore
627+ backend, location := "disk", blobdisk.Dir(cfg.DataDir)
628+ if cfg.S3Bucket == "" {
629+ blobs = blobdisk.New(location)
630+ } else {
631+ backend, location = "s3", cfg.S3Bucket
632+ store, err := blobs3.New(ctx, blobs3.Config{Bucket: cfg.S3Bucket, Prefix: cfg.S3Prefix, Endpoint: cfg.S3Endpoint, Region: cfg.S3Region, AccessKeyID: cfg.S3AccessKeyID, SecretAccessKey: cfg.S3SecretAccessKey, ForcePathStyle: cfg.S3ForcePathStyle})
633+ if err != nil {
634+ return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err)
635+ }
636+ blobs = store
637+ }
638+ probe, cancel := context.WithTimeout(ctx, 30*time.Second)
639+ defer cancel()
640+ if err := blobs.Check(probe); err != nil {
641+ if backend == "s3" {
642+ return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err)
643+ }
644+ return nil, fmt.Errorf("blob store: %w", err)
645+ }
646+ log := zerowrap.FromCtx(ctx)
647+ log.Info().Str("backend", backend).Str("location", location).Msg("blob store ready")
648+ return blobs, nil
649+}
650+
606651 // pullRequests gives the CI the merge requests, the refs/pull refs and the
607652 // identity of the test merge commits (CI-3, D5).
608653 func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {
A internal/app/release_e2e_test.go +160 -0
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package app
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "sync/atomic"
15+ "testing"
16+
17+ "bnema.dev/ermite/internal/adapters/blobdisk"
18+ "bnema.dev/ermite/internal/adapters/ciartifacts"
19+ "bnema.dev/ermite/internal/config"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+ "github.com/johannesboyne/gofakes3"
24+ "github.com/johannesboyne/gofakes3/backend/s3mem"
25+ "github.com/stretchr/testify/assert"
26+ "github.com/stretchr/testify/require"
27+)
28+
29+func TestReleaseBlobForge(t *testing.T) {
30+ for _, backend := range []string{"disk", "s3"} {
31+ t.Run(backend, func(t *testing.T) {
32+ env := map[string]string{}
33+ var admin atomic.Int64
34+ if backend == "s3" {
35+ mem := s3mem.New()
36+ require.NoError(t, mem.CreateBucket("assets"))
37+ fake := gofakes3.New(mem).Server()
38+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ // Allow only the startup probe/list and object/multipart subset. No bucket administration.
40+ bucket := r.URL.Path == "/assets" || r.URL.Path == "/assets/"
41+ allowed := true
42+ if bucket {
43+ allowed = r.Method == "HEAD" || (r.Method == "GET" && (r.URL.Query().Get("list-type") == "2" || r.URL.Query().Has("uploads")))
44+ }
45+ for _, key := range []string{"acl", "policy", "tagging", "lifecycle", "versioning", "cors", "notification", "encryption", "website", "logging"} {
46+ if r.URL.Query().Has(key) {
47+ allowed = false
48+ }
49+ }
50+ if !allowed {
51+ admin.Add(1)
52+ http.Error(w, "unexpected bucket operation", 500)
53+ return
54+ }
55+ fake.ServeHTTP(w, r)
56+ }))
57+ t.Cleanup(srv.Close)
58+ env = map[string]string{config.EnvS3Bucket: "assets", config.EnvS3Endpoint: srv.URL, config.EnvS3Region: "us-east-1", config.EnvS3AccessKeyID: "test-private-id", config.EnvS3SecretAccessKey: "test-private-secret", config.EnvS3ForcePathStyle: "1", config.EnvS3Prefix: "forge/"}
59+ }
60+ h := newHarness(t, harnessOpts{env: env})
61+ h.publicRepo("demo", "README.md")
62+ rel, err := h.forge.Releases.CreateRelease(h.ctx, domain.Root(), h.name("demo"), ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "main", Title: "Version one"})
63+ require.NoError(t, err)
64+ asset, err := h.forge.Releases.UploadAsset(h.ctx, domain.Root(), h.name("demo"), ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", Size: 7}, strings.NewReader("payload"))
65+ require.NoError(t, err)
66+ if backend == "disk" {
67+ data, err := os.ReadFile(filepath.Join(blobdisk.Dir(h.data), "objects", filepath.FromSlash(asset.BlobKey())))
68+ require.NoError(t, err)
69+ assert.Equal(t, "payload", string(data))
70+ }
71+ req, err := http.NewRequestWithContext(h.ctx, "GET", h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/assets/%d", asset.ID)), nil)
72+ require.NoError(t, err)
73+ req.Header.Set("Accept", "application/octet-stream")
74+ req.Header.Set("Range", "bytes=1-3")
75+ res, err := h.srv.Client().Do(req)
76+ require.NoError(t, err)
77+ body, err := io.ReadAll(res.Body)
78+ require.NoError(t, err)
79+ _ = res.Body.Close()
80+ assert.Equal(t, 206, res.StatusCode)
81+ assert.Equal(t, "ayl", string(body))
82+ assert.Empty(t, res.Header.Get("Location"))
83+ res, err = h.srv.Client().Get(h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/%d", rel.ID)))
84+ require.NoError(t, err)
85+ var obj map[string]any
86+ require.NoError(t, json.NewDecoder(res.Body).Decode(&obj))
87+ _ = res.Body.Close()
88+ assert.Equal(t, 200, res.StatusCode)
89+ assert.Contains(t, obj["upload_url"], "http://example.test/-/github/api/uploads/")
90+ // New artifacts use the same production backend, not a test-local blob store.
91+ wt := h.workTree("main")
92+ h.mustGit(wt, "fetch", "-q", h.urlAuth(adminToken, "/demo.git"), "main")
93+ h.mustGit(wt, "reset", "--hard", "FETCH_HEAD")
94+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "demo", wt: wt}
95+ runner := c.registerRunner("alpha", "ubuntu-latest")
96+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
97+ up := c.jobArtifacts(job)
98+ a := up.upload("dist", []byte("artifact bytes"))
99+ _, data, err := up.download(a.GetId(), 0)
100+ require.NoError(t, err)
101+ assert.Equal(t, "artifact bytes", string(data))
102+ record, err := h.forge.store.Artifacts().GetArtifact(h.ctx, a.GetId())
103+ require.NoError(t, err)
104+ assert.Equal(t, domain.CIArtifactStoreBlob, record.Store)
105+ _, err = h.forge.Blobs.Stat(h.ctx, record.BlobKey())
106+ require.NoError(t, err)
107+ assert.Contains(t, h.logs.String(), "blob store ready")
108+ assert.NotContains(t, h.logs.String(), "test-private-")
109+ assert.Zero(t, admin.Load(), "no bucket-admin operations")
110+ })
111+ }
112+}
113+
114+func TestBlobStartupProbe(t *testing.T) {
115+ _, err := blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Region: "us-east-1"})
116+ require.Error(t, err)
117+ assert.Contains(t, err.Error(), config.EnvS3Bucket)
118+ mem := s3mem.New()
119+ srv := httptest.NewServer(gofakes3.New(mem).Server())
120+ defer srv.Close()
121+ _, err = blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Endpoint: srv.URL, S3Region: "us-east-1", S3AccessKeyID: "private-id", S3SecretAccessKey: "private-secret", S3ForcePathStyle: true})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "ERMITE_S3_BUCKET")
124+ assert.Contains(t, err.Error(), "not reachable")
125+ assert.NotContains(t, err.Error(), "private-")
126+}
127+
128+func TestArtifactLegacyProductionWiring(t *testing.T) {
129+ // Seed the old tree before forge startup, exactly as existing installations do.
130+ data, err := os.MkdirTemp("", "ermite-legacy")
131+ require.NoError(t, err)
132+ t.Cleanup(func() { _ = os.RemoveAll(data) })
133+ require.NoError(t, prepareDataDir(data))
134+ require.NoError(t, os.MkdirAll(ciartifacts.Dir(data), 0700))
135+ h := newHarness(t, harnessOpts{env: map[string]string{config.EnvDataDir: data}})
136+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj", wt: h.workTree("main")}
137+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
138+ require.NoError(t, err)
139+ runner := c.registerRunner("alpha", "ubuntu-latest")
140+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
141+ up := c.jobArtifacts(job)
142+ created := up.upload("old", []byte("legacy bytes"))
143+ store := h.forge.store.Artifacts()
144+ a, err := store.GetArtifact(h.ctx, created.GetId())
145+ require.NoError(t, err)
146+ path := filepath.Join(ciartifacts.Dir(data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
147+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
148+ require.NoError(t, os.WriteFile(path, []byte("legacy bytes"), 0600))
149+ require.NoError(t, h.forge.Blobs.Delete(h.ctx, a.BlobKey()))
150+ a.Store = domain.CIArtifactStoreLegacy
151+ used, version, err := store.ArtifactUsage(h.ctx, a.Repo)
152+ require.NoError(t, err)
153+ require.NoError(t, store.ApplyArtifacts(h.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
154+ _, body, err := up.download(a.ID, 0)
155+ require.NoError(t, err)
156+ assert.Equal(t, "legacy bytes", string(body))
157+ // A fresh install does not need or create the legacy tree.
158+ fresh := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = func(cfg *usecase.CIConfig) { assert.Nil(t, cfg.ArtifactFiles) } })
159+ assert.NoDirExists(t, ciartifacts.Dir(fresh.data))
160+}
M internal/app/root.go +1 -1
@@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
3333 newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34- newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
34+ newMRCmd(env), newIssueCmd(env), newReleaseCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
3737 return root
M internal/app/testdata/web/blob-binary.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -358,31 +362,37 @@
358362 <span class="k">section</span>
359363 </a>
360364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362372 <a href="/files/actions" tabindex="-1">
363373 <span class="lbl">actions</span>
364374 <span class="k">section</span>
365375 </a>
366376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368378 <a href="/files/tree/feature/x" tabindex="-1">
369379 <span class="lbl">feature/x</span>
370380 <span class="k">branch</span>
371381 </a>
372382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374384 <a href="/files/tree/main" tabindex="-1">
375385 <span class="lbl">main</span>
376386 <span class="k">branch</span>
377387 </a>
378388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380390 <a href="/files/tree/v1.0" tabindex="-1">
381391 <span class="lbl">v1.0</span>
382392 <span class="k">tag</span>
383393 </a>
384394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386396 <a href="#" data-fire="keys" tabindex="-1">
387397 <span class="lbl">turn single-key shortcuts off</span>
388398 <span class="k">action</span>
M internal/app/testdata/web/blob-code.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -428,31 +432,37 @@
428432 <span class="k">section</span>
429433 </a>
430434 </li>
431-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
435+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
436+<a href="/files/releases" tabindex="-1">
437+<span class="lbl">releases</span>
438+<span class="k">section</span>
439+</a>
440+</li>
441+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
432442 <a href="/files/actions" tabindex="-1">
433443 <span class="lbl">actions</span>
434444 <span class="k">section</span>
435445 </a>
436446 </li>
437-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
447+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
438448 <a href="/files/tree/feature/x" tabindex="-1">
439449 <span class="lbl">feature/x</span>
440450 <span class="k">branch</span>
441451 </a>
442452 </li>
443-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
453+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
444454 <a href="/files/tree/main" tabindex="-1">
445455 <span class="lbl">main</span>
446456 <span class="k">branch</span>
447457 </a>
448458 </li>
449-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
459+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
450460 <a href="/files/tree/v1.0" tabindex="-1">
451461 <span class="lbl">v1.0</span>
452462 <span class="k">tag</span>
453463 </a>
454464 </li>
455-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
465+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
456466 <a href="#" data-fire="keys" tabindex="-1">
457467 <span class="lbl">turn single-key shortcuts off</span>
458468 <span class="k">action</span>
M internal/app/testdata/web/blob-empty.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -358,31 +362,37 @@
358362 <span class="k">section</span>
359363 </a>
360364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362372 <a href="/files/actions" tabindex="-1">
363373 <span class="lbl">actions</span>
364374 <span class="k">section</span>
365375 </a>
366376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368378 <a href="/files/tree/feature/x" tabindex="-1">
369379 <span class="lbl">feature/x</span>
370380 <span class="k">branch</span>
371381 </a>
372382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374384 <a href="/files/tree/main" tabindex="-1">
375385 <span class="lbl">main</span>
376386 <span class="k">branch</span>
377387 </a>
378388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380390 <a href="/files/tree/v1.0" tabindex="-1">
381391 <span class="lbl">v1.0</span>
382392 <span class="k">tag</span>
383393 </a>
384394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386396 <a href="#" data-fire="keys" tabindex="-1">
387397 <span class="lbl">turn single-key shortcuts off</span>
388398 <span class="k">action</span>
M internal/app/testdata/web/blob-image.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -360,31 +364,37 @@
360364 <span class="k">section</span>
361365 </a>
362366 </li>
363-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
367+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
368+<a href="/files/releases" tabindex="-1">
369+<span class="lbl">releases</span>
370+<span class="k">section</span>
371+</a>
372+</li>
373+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
364374 <a href="/files/actions" tabindex="-1">
365375 <span class="lbl">actions</span>
366376 <span class="k">section</span>
367377 </a>
368378 </li>
369-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
379+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
370380 <a href="/files/tree/feature/x" tabindex="-1">
371381 <span class="lbl">feature/x</span>
372382 <span class="k">branch</span>
373383 </a>
374384 </li>
375-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
385+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
376386 <a href="/files/tree/main" tabindex="-1">
377387 <span class="lbl">main</span>
378388 <span class="k">branch</span>
379389 </a>
380390 </li>
381-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
391+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
382392 <a href="/files/tree/v1.0" tabindex="-1">
383393 <span class="lbl">v1.0</span>
384394 <span class="k">tag</span>
385395 </a>
386396 </li>
387-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
397+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
388398 <a href="#" data-fire="keys" tabindex="-1">
389399 <span class="lbl">turn single-key shortcuts off</span>
390400 <span class="k">action</span>
M internal/app/testdata/web/blob-markdown.html +15 -5
@@ -96,6 +96,10 @@
9696 </a>
9797 </li>
9898 <li>
99+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
100+</a>
101+</li>
102+<li>
99103 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100104 </a>
101105 </li>
@@ -425,31 +429,37 @@
425429 <span class="k">section</span>
426430 </a>
427431 </li>
428-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
432+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
433+<a href="/files/releases" tabindex="-1">
434+<span class="lbl">releases</span>
435+<span class="k">section</span>
436+</a>
437+</li>
438+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
429439 <a href="/files/actions" tabindex="-1">
430440 <span class="lbl">actions</span>
431441 <span class="k">section</span>
432442 </a>
433443 </li>
434-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
444+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
435445 <a href="/files/tree/feature/x" tabindex="-1">
436446 <span class="lbl">feature/x</span>
437447 <span class="k">branch</span>
438448 </a>
439449 </li>
440-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
450+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
441451 <a href="/files/tree/main" tabindex="-1">
442452 <span class="lbl">main</span>
443453 <span class="k">branch</span>
444454 </a>
445455 </li>
446-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
456+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
447457 <a href="/files/tree/v1.0" tabindex="-1">
448458 <span class="lbl">v1.0</span>
449459 <span class="k">tag</span>
450460 </a>
451461 </li>
452-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
462+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
453463 <a href="#" data-fire="keys" tabindex="-1">
454464 <span class="lbl">turn single-key shortcuts off</span>
455465 <span class="k">action</span>
M internal/app/testdata/web/blob-svg.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -383,31 +387,37 @@
383387 <span class="k">section</span>
384388 </a>
385389 </li>
386-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
390+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
391+<a href="/files/releases" tabindex="-1">
392+<span class="lbl">releases</span>
393+<span class="k">section</span>
394+</a>
395+</li>
396+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
387397 <a href="/files/actions" tabindex="-1">
388398 <span class="lbl">actions</span>
389399 <span class="k">section</span>
390400 </a>
391401 </li>
392-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
402+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
393403 <a href="/files/tree/feature/x" tabindex="-1">
394404 <span class="lbl">feature/x</span>
395405 <span class="k">branch</span>
396406 </a>
397407 </li>
398-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
408+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
399409 <a href="/files/tree/main" tabindex="-1">
400410 <span class="lbl">main</span>
401411 <span class="k">branch</span>
402412 </a>
403413 </li>
404-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
414+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
405415 <a href="/files/tree/v1.0" tabindex="-1">
406416 <span class="lbl">v1.0</span>
407417 <span class="k">tag</span>
408418 </a>
409419 </li>
410-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
420+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
411421 <a href="#" data-fire="keys" tabindex="-1">
412422 <span class="lbl">turn single-key shortcuts off</span>
413423 <span class="k">action</span>
M internal/app/testdata/web/blob-text.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -378,31 +382,37 @@
378382 <span class="k">section</span>
379383 </a>
380384 </li>
381-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
385+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
386+<a href="/files/releases" tabindex="-1">
387+<span class="lbl">releases</span>
388+<span class="k">section</span>
389+</a>
390+</li>
391+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
382392 <a href="/files/actions" tabindex="-1">
383393 <span class="lbl">actions</span>
384394 <span class="k">section</span>
385395 </a>
386396 </li>
387-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
397+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
388398 <a href="/files/tree/feature/x" tabindex="-1">
389399 <span class="lbl">feature/x</span>
390400 <span class="k">branch</span>
391401 </a>
392402 </li>
393-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
403+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
394404 <a href="/files/tree/main" tabindex="-1">
395405 <span class="lbl">main</span>
396406 <span class="k">branch</span>
397407 </a>
398408 </li>
399-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
409+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
400410 <a href="/files/tree/v1.0" tabindex="-1">
401411 <span class="lbl">v1.0</span>
402412 <span class="k">tag</span>
403413 </a>
404414 </li>
405-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
415+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
406416 <a href="#" data-fire="keys" tabindex="-1">
407417 <span class="lbl">turn single-key shortcuts off</span>
408418 <span class="k">action</span>
M internal/app/testdata/web/blob-too-large.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -358,31 +362,37 @@
358362 <span class="k">section</span>
359363 </a>
360364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362372 <a href="/files/actions" tabindex="-1">
363373 <span class="lbl">actions</span>
364374 <span class="k">section</span>
365375 </a>
366376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368378 <a href="/files/tree/feature/x" tabindex="-1">
369379 <span class="lbl">feature/x</span>
370380 <span class="k">branch</span>
371381 </a>
372382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374384 <a href="/files/tree/main" tabindex="-1">
375385 <span class="lbl">main</span>
376386 <span class="k">branch</span>
377387 </a>
378388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380390 <a href="/files/tree/v1.0" tabindex="-1">
381391 <span class="lbl">v1.0</span>
382392 <span class="k">tag</span>
383393 </a>
384394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386396 <a href="#" data-fire="keys" tabindex="-1">
387397 <span class="lbl">turn single-key shortcuts off</span>
388398 <span class="k">action</span>
M internal/app/testdata/web/blob-trojan.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -425,31 +429,37 @@
425429 <span class="k">section</span>
426430 </a>
427431 </li>
428-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
432+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
433+<a href="/files/releases" tabindex="-1">
434+<span class="lbl">releases</span>
435+<span class="k">section</span>
436+</a>
437+</li>
438+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
429439 <a href="/files/actions" tabindex="-1">
430440 <span class="lbl">actions</span>
431441 <span class="k">section</span>
432442 </a>
433443 </li>
434-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
444+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
435445 <a href="/files/tree/feature/x" tabindex="-1">
436446 <span class="lbl">feature/x</span>
437447 <span class="k">branch</span>
438448 </a>
439449 </li>
440-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
450+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
441451 <a href="/files/tree/main" tabindex="-1">
442452 <span class="lbl">main</span>
443453 <span class="k">branch</span>
444454 </a>
445455 </li>
446-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
456+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
447457 <a href="/files/tree/v1.0" tabindex="-1">
448458 <span class="lbl">v1.0</span>
449459 <span class="k">tag</span>
450460 </a>
451461 </li>
452-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
462+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
453463 <a href="#" data-fire="keys" tabindex="-1">
454464 <span class="lbl">turn single-key shortcuts off</span>
455465 <span class="k">action</span>
M internal/app/testdata/web/commit-big.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -410,31 +414,37 @@
410414 <span class="k">section</span>
411415 </a>
412416 </li>
413-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
417+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
418+<a href="/diffs/releases" tabindex="-1">
419+<span class="lbl">releases</span>
420+<span class="k">section</span>
421+</a>
422+</li>
423+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
414424 <a href="/diffs/actions" tabindex="-1">
415425 <span class="lbl">actions</span>
416426 <span class="k">section</span>
417427 </a>
418428 </li>
419-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
429+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
420430 <a href="/diffs/tree/main" tabindex="-1">
421431 <span class="lbl">main</span>
422432 <span class="k">branch</span>
423433 </a>
424434 </li>
425-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
435+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
426436 <a href="/diffs/tree/side" tabindex="-1">
427437 <span class="lbl">side</span>
428438 <span class="k">branch</span>
429439 </a>
430440 </li>
431-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
441+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
432442 <a href="/diffs/tree/v1.0.0" tabindex="-1">
433443 <span class="lbl">v1.0.0</span>
434444 <span class="k">tag</span>
435445 </a>
436446 </li>
437-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
447+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
438448 <a href="#" data-fire="keys" tabindex="-1">
439449 <span class="lbl">turn single-key shortcuts off</span>
440450 <span class="k">action</span>
M internal/app/testdata/web/commit-binary.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -401,31 +405,37 @@
401405 <span class="k">section</span>
402406 </a>
403407 </li>
404-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
408+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
409+<a href="/diffs/releases" tabindex="-1">
410+<span class="lbl">releases</span>
411+<span class="k">section</span>
412+</a>
413+</li>
414+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
405415 <a href="/diffs/actions" tabindex="-1">
406416 <span class="lbl">actions</span>
407417 <span class="k">section</span>
408418 </a>
409419 </li>
410-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
420+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
411421 <a href="/diffs/tree/main" tabindex="-1">
412422 <span class="lbl">main</span>
413423 <span class="k">branch</span>
414424 </a>
415425 </li>
416-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
426+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
417427 <a href="/diffs/tree/side" tabindex="-1">
418428 <span class="lbl">side</span>
419429 <span class="k">branch</span>
420430 </a>
421431 </li>
422-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
432+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
423433 <a href="/diffs/tree/v1.0.0" tabindex="-1">
424434 <span class="lbl">v1.0.0</span>
425435 <span class="k">tag</span>
426436 </a>
427437 </li>
428-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
438+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
429439 <a href="#" data-fire="keys" tabindex="-1">
430440 <span class="lbl">turn single-key shortcuts off</span>
431441 <span class="k">action</span>
M internal/app/testdata/web/commit-merge.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -443,31 +447,37 @@
443447 <span class="k">section</span>
444448 </a>
445449 </li>
446-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
450+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
451+<a href="/diffs/releases" tabindex="-1">
452+<span class="lbl">releases</span>
453+<span class="k">section</span>
454+</a>
455+</li>
456+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
447457 <a href="/diffs/actions" tabindex="-1">
448458 <span class="lbl">actions</span>
449459 <span class="k">section</span>
450460 </a>
451461 </li>
452-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
462+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
453463 <a href="/diffs/tree/main" tabindex="-1">
454464 <span class="lbl">main</span>
455465 <span class="k">branch</span>
456466 </a>
457467 </li>
458-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
468+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
459469 <a href="/diffs/tree/side" tabindex="-1">
460470 <span class="lbl">side</span>
461471 <span class="k">branch</span>
462472 </a>
463473 </li>
464-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
474+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
465475 <a href="/diffs/tree/v1.0.0" tabindex="-1">
466476 <span class="lbl">v1.0.0</span>
467477 <span class="k">tag</span>
468478 </a>
469479 </li>
470-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
480+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
471481 <a href="#" data-fire="keys" tabindex="-1">
472482 <span class="lbl">turn single-key shortcuts off</span>
473483 <span class="k">action</span>
M internal/app/testdata/web/commit-modify-split.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -566,31 +570,37 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566570 <span class="k">section</span>
567571 </a>
568572 </li>
569-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
573+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
574+<a href="/diffs/releases" tabindex="-1">
575+<span class="lbl">releases</span>
576+<span class="k">section</span>
577+</a>
578+</li>
579+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
570580 <a href="/diffs/actions" tabindex="-1">
571581 <span class="lbl">actions</span>
572582 <span class="k">section</span>
573583 </a>
574584 </li>
575-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
585+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
576586 <a href="/diffs/tree/main" tabindex="-1">
577587 <span class="lbl">main</span>
578588 <span class="k">branch</span>
579589 </a>
580590 </li>
581-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
591+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
582592 <a href="/diffs/tree/side" tabindex="-1">
583593 <span class="lbl">side</span>
584594 <span class="k">branch</span>
585595 </a>
586596 </li>
587-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
597+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
588598 <a href="/diffs/tree/v1.0.0" tabindex="-1">
589599 <span class="lbl">v1.0.0</span>
590600 <span class="k">tag</span>
591601 </a>
592602 </li>
593-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
603+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
594604 <a href="#" data-fire="keys" tabindex="-1">
595605 <span class="lbl">turn single-key shortcuts off</span>
596606 <span class="k">action</span>
M internal/app/testdata/web/commit-modify.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -566,31 +570,37 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566570 <span class="k">section</span>
567571 </a>
568572 </li>
569-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
573+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
574+<a href="/diffs/releases" tabindex="-1">
575+<span class="lbl">releases</span>
576+<span class="k">section</span>
577+</a>
578+</li>
579+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
570580 <a href="/diffs/actions" tabindex="-1">
571581 <span class="lbl">actions</span>
572582 <span class="k">section</span>
573583 </a>
574584 </li>
575-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
585+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
576586 <a href="/diffs/tree/main" tabindex="-1">
577587 <span class="lbl">main</span>
578588 <span class="k">branch</span>
579589 </a>
580590 </li>
581-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
591+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
582592 <a href="/diffs/tree/side" tabindex="-1">
583593 <span class="lbl">side</span>
584594 <span class="k">branch</span>
585595 </a>
586596 </li>
587-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
597+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
588598 <a href="/diffs/tree/v1.0.0" tabindex="-1">
589599 <span class="lbl">v1.0.0</span>
590600 <span class="k">tag</span>
591601 </a>
592602 </li>
593-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
603+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
594604 <a href="#" data-fire="keys" tabindex="-1">
595605 <span class="lbl">turn single-key shortcuts off</span>
596606 <span class="k">action</span>
M internal/app/testdata/web/commit-rename.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -470,31 +474,37 @@
470474 <span class="k">section</span>
471475 </a>
472476 </li>
473-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
477+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
478+<a href="/diffs/releases" tabindex="-1">
479+<span class="lbl">releases</span>
480+<span class="k">section</span>
481+</a>
482+</li>
483+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
474484 <a href="/diffs/actions" tabindex="-1">
475485 <span class="lbl">actions</span>
476486 <span class="k">section</span>
477487 </a>
478488 </li>
479-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
489+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
480490 <a href="/diffs/tree/main" tabindex="-1">
481491 <span class="lbl">main</span>
482492 <span class="k">branch</span>
483493 </a>
484494 </li>
485-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
495+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
486496 <a href="/diffs/tree/side" tabindex="-1">
487497 <span class="lbl">side</span>
488498 <span class="k">branch</span>
489499 </a>
490500 </li>
491-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
501+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
492502 <a href="/diffs/tree/v1.0.0" tabindex="-1">
493503 <span class="lbl">v1.0.0</span>
494504 <span class="k">tag</span>
495505 </a>
496506 </li>
497-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
507+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
498508 <a href="#" data-fire="keys" tabindex="-1">
499509 <span class="lbl">turn single-key shortcuts off</span>
500510 <span class="k">action</span>
M internal/app/testdata/web/commit-root.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -789,31 +793,37 @@
789793 <span class="k">section</span>
790794 </a>
791795 </li>
792-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
796+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
797+<a href="/diffs/releases" tabindex="-1">
798+<span class="lbl">releases</span>
799+<span class="k">section</span>
800+</a>
801+</li>
802+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
793803 <a href="/diffs/actions" tabindex="-1">
794804 <span class="lbl">actions</span>
795805 <span class="k">section</span>
796806 </a>
797807 </li>
798-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
808+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
799809 <a href="/diffs/tree/main" tabindex="-1">
800810 <span class="lbl">main</span>
801811 <span class="k">branch</span>
802812 </a>
803813 </li>
804-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
814+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
805815 <a href="/diffs/tree/side" tabindex="-1">
806816 <span class="lbl">side</span>
807817 <span class="k">branch</span>
808818 </a>
809819 </li>
810-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
820+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
811821 <a href="/diffs/tree/v1.0.0" tabindex="-1">
812822 <span class="lbl">v1.0.0</span>
813823 <span class="k">tag</span>
814824 </a>
815825 </li>
816-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
826+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
817827 <a href="#" data-fire="keys" tabindex="-1">
818828 <span class="lbl">turn single-key shortcuts off</span>
819829 <span class="k">action</span>
M internal/app/testdata/web/commit-trojan.html +15 -5
@@ -92,6 +92,10 @@
9292 </a>
9393 </li>
9494 <li>
95+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+</a>
97+</li>
98+<li>
9599 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96100 </a>
97101 </li>
@@ -618,31 +622,37 @@
618622 <span class="k">section</span>
619623 </a>
620624 </li>
621-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
625+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
626+<a href="/diffs/releases" tabindex="-1">
627+<span class="lbl">releases</span>
628+<span class="k">section</span>
629+</a>
630+</li>
631+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
622632 <a href="/diffs/actions" tabindex="-1">
623633 <span class="lbl">actions</span>
624634 <span class="k">section</span>
625635 </a>
626636 </li>
627-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
637+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
628638 <a href="/diffs/tree/main" tabindex="-1">
629639 <span class="lbl">main</span>
630640 <span class="k">branch</span>
631641 </a>
632642 </li>
633-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
643+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
634644 <a href="/diffs/tree/side" tabindex="-1">
635645 <span class="lbl">side</span>
636646 <span class="k">branch</span>
637647 </a>
638648 </li>
639-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
649+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
640650 <a href="/diffs/tree/v1.0.0" tabindex="-1">
641651 <span class="lbl">v1.0.0</span>
642652 <span class="k">tag</span>
643653 </a>
644654 </li>
645-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
655+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
646656 <a href="#" data-fire="keys" tabindex="-1">
647657 <span class="lbl">turn single-key shortcuts off</span>
648658 <span class="k">action</span>
M internal/app/testdata/web/commits-page2.html +14 -4
@@ -90,6 +90,10 @@
9090 </a>
9191 </li>
9292 <li>
93+<a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
94+</a>
95+</li>
96+<li>
9397 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9498 </a>
9599 </li>
@@ -377,25 +381,31 @@
377381 <span class="k">section</span>
378382 </a>
379383 </li>
380-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
384+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
385+<a href="/long/releases" tabindex="-1">
386+<span class="lbl">releases</span>
387+<span class="k">section</span>
388+</a>
389+</li>
390+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
381391 <a href="/long/actions" tabindex="-1">
382392 <span class="lbl">actions</span>
383393 <span class="k">section</span>
384394 </a>
385395 </li>
386-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
396+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
387397 <a href="/long/tree/main" tabindex="-1">
388398 <span class="lbl">main</span>
389399 <span class="k">branch</span>
390400 </a>
391401 </li>
392-<li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs">
402+<li id="cmd-o9" role="option" aria-selected="false" data-k="repo diffs">
393403 <a href="/diffs" tabindex="-1">
394404 <span class="lbl">diffs</span>
395405 <span class="k">repo</span>
396406 </a>
397407 </li>
398-<li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
408+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
399409 <a href="#" data-fire="keys" tabindex="-1">
400410 <span class="lbl">turn single-key shortcuts off</span>
401411 <span class="k">action</span>
M internal/app/testdata/web/commits-path.html +14 -4
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -392,25 +396,31 @@
392396 <span class="k">section</span>
393397 </a>
394398 </li>
395-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
399+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
400+<a href="/long/releases" tabindex="-1">
401+<span class="lbl">releases</span>
402+<span class="k">section</span>
403+</a>
404+</li>
405+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
396406 <a href="/long/actions" tabindex="-1">
397407 <span class="lbl">actions</span>
398408 <span class="k">section</span>
399409 </a>
400410 </li>
401-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
411+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
402412 <a href="/long/tree/main" tabindex="-1">
403413 <span class="lbl">main</span>
404414 <span class="k">branch</span>
405415 </a>
406416 </li>
407-<li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs">
417+<li id="cmd-o9" role="option" aria-selected="false" data-k="repo diffs">
408418 <a href="/diffs" tabindex="-1">
409419 <span class="lbl">diffs</span>
410420 <span class="k">repo</span>
411421 </a>
412422 </li>
413-<li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
423+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
414424 <a href="#" data-fire="keys" tabindex="-1">
415425 <span class="lbl">turn single-key shortcuts off</span>
416426 <span class="k">action</span>
M internal/app/testdata/web/commits-root.html +14 -4
@@ -90,6 +90,10 @@
9090 </a>
9191 </li>
9292 <li>
93+<a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
94+</a>
95+</li>
96+<li>
9397 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9498 </a>
9599 </li>
@@ -1182,25 +1186,31 @@
11821186 <span class="k">section</span>
11831187 </a>
11841188 </li>
1185-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
1189+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
1190+<a href="/long/releases" tabindex="-1">
1191+<span class="lbl">releases</span>
1192+<span class="k">section</span>
1193+</a>
1194+</li>
1195+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
11861196 <a href="/long/actions" tabindex="-1">
11871197 <span class="lbl">actions</span>
11881198 <span class="k">section</span>
11891199 </a>
11901200 </li>
1191-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
1201+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
11921202 <a href="/long/tree/main" tabindex="-1">
11931203 <span class="lbl">main</span>
11941204 <span class="k">branch</span>
11951205 </a>
11961206 </li>
1197-<li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs">
1207+<li id="cmd-o9" role="option" aria-selected="false" data-k="repo diffs">
11981208 <a href="/diffs" tabindex="-1">
11991209 <span class="lbl">diffs</span>
12001210 <span class="k">repo</span>
12011211 </a>
12021212 </li>
1203-<li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
1213+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
12041214 <a href="#" data-fire="keys" tabindex="-1">
12051215 <span class="lbl">turn single-key shortcuts off</span>
12061216 <span class="k">action</span>
M internal/app/testdata/web/commits-small.html +16 -6
@@ -90,6 +90,10 @@
9090 </a>
9191 </li>
9292 <li>
93+<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
94+</a>
95+</li>
96+<li>
9397 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9498 </a>
9599 </li>
@@ -535,37 +539,43 @@
535539 <span class="k">section</span>
536540 </a>
537541 </li>
538-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
542+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
543+<a href="/diffs/releases" tabindex="-1">
544+<span class="lbl">releases</span>
545+<span class="k">section</span>
546+</a>
547+</li>
548+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
539549 <a href="/diffs/actions" tabindex="-1">
540550 <span class="lbl">actions</span>
541551 <span class="k">section</span>
542552 </a>
543553 </li>
544-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
554+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
545555 <a href="/diffs/tree/main" tabindex="-1">
546556 <span class="lbl">main</span>
547557 <span class="k">branch</span>
548558 </a>
549559 </li>
550-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
560+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch side">
551561 <a href="/diffs/tree/side" tabindex="-1">
552562 <span class="lbl">side</span>
553563 <span class="k">branch</span>
554564 </a>
555565 </li>
556-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
566+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0">
557567 <a href="/diffs/tree/v1.0.0" tabindex="-1">
558568 <span class="lbl">v1.0.0</span>
559569 <span class="k">tag</span>
560570 </a>
561571 </li>
562-<li id="cmd-o10" role="option" aria-selected="false" data-k="repo long">
572+<li id="cmd-o11" role="option" aria-selected="false" data-k="repo long">
563573 <a href="/long" tabindex="-1">
564574 <span class="lbl">long</span>
565575 <span class="k">repo</span>
566576 </a>
567577 </li>
568-<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
578+<li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
569579 <a href="#" data-fire="keys" tabindex="-1">
570580 <span class="lbl">turn single-key shortcuts off</span>
571581 <span class="k">action</span>
M internal/app/testdata/web/refs.html +17 -7
@@ -87,6 +87,10 @@
8787 </a>
8888 </li>
8989 <li>
90+<a href="/demo/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
91+</a>
92+</li>
93+<li>
9094 <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9195 </a>
9296 </li>
@@ -421,43 +425,49 @@
421425 <span class="k">section</span>
422426 </a>
423427 </li>
424-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
428+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
429+<a href="/demo/releases" tabindex="-1">
430+<span class="lbl">releases</span>
431+<span class="k">section</span>
432+</a>
433+</li>
434+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
425435 <a href="/demo/actions" tabindex="-1">
426436 <span class="lbl">actions</span>
427437 <span class="k">section</span>
428438 </a>
429439 </li>
430-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
440+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
431441 <a href="/demo/tree/feature/x" tabindex="-1">
432442 <span class="lbl">feature/x</span>
433443 <span class="k">branch</span>
434444 </a>
435445 </li>
436-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
446+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
437447 <a href="/demo/tree/main" tabindex="-1">
438448 <span class="lbl">main</span>
439449 <span class="k">branch</span>
440450 </a>
441451 </li>
442-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v0.1.0">
452+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.1.0">
443453 <a href="/demo/tree/v0.1.0" tabindex="-1">
444454 <span class="lbl">v0.1.0</span>
445455 <span class="k">tag</span>
446456 </a>
447457 </li>
448-<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.2.0">
458+<li id="cmd-o11" role="option" aria-selected="false" data-k="tag v0.2.0">
449459 <a href="/demo/tree/v0.2.0" tabindex="-1">
450460 <span class="lbl">v0.2.0</span>
451461 <span class="k">tag</span>
452462 </a>
453463 </li>
454-<li id="cmd-o11" role="option" aria-selected="false" data-k="repo quiet">
464+<li id="cmd-o12" role="option" aria-selected="false" data-k="repo quiet">
455465 <a href="/quiet" tabindex="-1">
456466 <span class="lbl">quiet</span>
457467 <span class="k">repo</span>
458468 </a>
459469 </li>
460-<li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
470+<li id="cmd-o13" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
461471 <a href="#" data-fire="keys" tabindex="-1">
462472 <span class="lbl">turn single-key shortcuts off</span>
463473 <span class="k">action</span>
M internal/app/testdata/web/summary.html +17 -7
@@ -84,6 +84,10 @@
8484 </a>
8585 </li>
8686 <li>
87+<a href="/demo/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
88+</a>
89+</li>
90+<li>
8791 <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
8892 </a>
8993 </li>
@@ -447,43 +451,49 @@
447451 <span class="k">section</span>
448452 </a>
449453 </li>
450-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
454+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
455+<a href="/demo/releases" tabindex="-1">
456+<span class="lbl">releases</span>
457+<span class="k">section</span>
458+</a>
459+</li>
460+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
451461 <a href="/demo/actions" tabindex="-1">
452462 <span class="lbl">actions</span>
453463 <span class="k">section</span>
454464 </a>
455465 </li>
456-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
466+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
457467 <a href="/demo/tree/feature/x" tabindex="-1">
458468 <span class="lbl">feature/x</span>
459469 <span class="k">branch</span>
460470 </a>
461471 </li>
462-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
472+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
463473 <a href="/demo/tree/main" tabindex="-1">
464474 <span class="lbl">main</span>
465475 <span class="k">branch</span>
466476 </a>
467477 </li>
468-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v0.1.0">
478+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.1.0">
469479 <a href="/demo/tree/v0.1.0" tabindex="-1">
470480 <span class="lbl">v0.1.0</span>
471481 <span class="k">tag</span>
472482 </a>
473483 </li>
474-<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.2.0">
484+<li id="cmd-o11" role="option" aria-selected="false" data-k="tag v0.2.0">
475485 <a href="/demo/tree/v0.2.0" tabindex="-1">
476486 <span class="lbl">v0.2.0</span>
477487 <span class="k">tag</span>
478488 </a>
479489 </li>
480-<li id="cmd-o11" role="option" aria-selected="false" data-k="repo quiet">
490+<li id="cmd-o12" role="option" aria-selected="false" data-k="repo quiet">
481491 <a href="/quiet" tabindex="-1">
482492 <span class="lbl">quiet</span>
483493 <span class="k">repo</span>
484494 </a>
485495 </li>
486-<li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
496+<li id="cmd-o13" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
487497 <a href="#" data-fire="keys" tabindex="-1">
488498 <span class="lbl">turn single-key shortcuts off</span>
489499 <span class="k">action</span>
M internal/app/testdata/web/tree-nested.html +15 -5
@@ -96,6 +96,10 @@
9696 </a>
9797 </li>
9898 <li>
99+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
100+</a>
101+</li>
102+<li>
99103 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100104 </a>
101105 </li>
@@ -373,31 +377,37 @@
373377 <span class="k">section</span>
374378 </a>
375379 </li>
376-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
380+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
381+<a href="/files/releases" tabindex="-1">
382+<span class="lbl">releases</span>
383+<span class="k">section</span>
384+</a>
385+</li>
386+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
377387 <a href="/files/actions" tabindex="-1">
378388 <span class="lbl">actions</span>
379389 <span class="k">section</span>
380390 </a>
381391 </li>
382-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
392+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
383393 <a href="/files/tree/feature/x" tabindex="-1">
384394 <span class="lbl">feature/x</span>
385395 <span class="k">branch</span>
386396 </a>
387397 </li>
388-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
398+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
389399 <a href="/files/tree/main" tabindex="-1">
390400 <span class="lbl">main</span>
391401 <span class="k">branch</span>
392402 </a>
393403 </li>
394-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
404+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
395405 <a href="/files/tree/v1.0" tabindex="-1">
396406 <span class="lbl">v1.0</span>
397407 <span class="k">tag</span>
398408 </a>
399409 </li>
400-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
410+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
401411 <a href="#" data-fire="keys" tabindex="-1">
402412 <span class="lbl">turn single-key shortcuts off</span>
403413 <span class="k">action</span>
M internal/app/testdata/web/tree-root.html +15 -5
@@ -90,6 +90,10 @@
9090 </a>
9191 </li>
9292 <li>
93+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
94+</a>
95+</li>
96+<li>
9397 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9498 </a>
9599 </li>
@@ -594,31 +598,37 @@
594598 <span class="k">section</span>
595599 </a>
596600 </li>
597-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
601+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
602+<a href="/files/releases" tabindex="-1">
603+<span class="lbl">releases</span>
604+<span class="k">section</span>
605+</a>
606+</li>
607+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
598608 <a href="/files/actions" tabindex="-1">
599609 <span class="lbl">actions</span>
600610 <span class="k">section</span>
601611 </a>
602612 </li>
603-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
613+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
604614 <a href="/files/tree/feature/x" tabindex="-1">
605615 <span class="lbl">feature/x</span>
606616 <span class="k">branch</span>
607617 </a>
608618 </li>
609-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
619+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
610620 <a href="/files/tree/main" tabindex="-1">
611621 <span class="lbl">main</span>
612622 <span class="k">branch</span>
613623 </a>
614624 </li>
615-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
625+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
616626 <a href="/files/tree/v1.0" tabindex="-1">
617627 <span class="lbl">v1.0</span>
618628 <span class="k">tag</span>
619629 </a>
620630 </li>
621-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
631+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
622632 <a href="#" data-fire="keys" tabindex="-1">
623633 <span class="lbl">turn single-key shortcuts off</span>
624634 <span class="k">action</span>
M internal/app/testdata/web/tree-slash-ref.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -370,31 +374,37 @@
370374 <span class="k">section</span>
371375 </a>
372376 </li>
373-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
377+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
378+<a href="/files/releases" tabindex="-1">
379+<span class="lbl">releases</span>
380+<span class="k">section</span>
381+</a>
382+</li>
383+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
374384 <a href="/files/actions" tabindex="-1">
375385 <span class="lbl">actions</span>
376386 <span class="k">section</span>
377387 </a>
378388 </li>
379-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
389+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
380390 <a href="/files/tree/feature/x" tabindex="-1">
381391 <span class="lbl">feature/x</span>
382392 <span class="k">branch</span>
383393 </a>
384394 </li>
385-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
395+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
386396 <a href="/files/tree/main" tabindex="-1">
387397 <span class="lbl">main</span>
388398 <span class="k">branch</span>
389399 </a>
390400 </li>
391-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
401+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
392402 <a href="/files/tree/v1.0" tabindex="-1">
393403 <span class="lbl">v1.0</span>
394404 <span class="k">tag</span>
395405 </a>
396406 </li>
397-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
407+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
398408 <a href="#" data-fire="keys" tabindex="-1">
399409 <span class="lbl">turn single-key shortcuts off</span>
400410 <span class="k">action</span>
M internal/app/testdata/web/tree-subdir.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -386,31 +390,37 @@
386390 <span class="k">section</span>
387391 </a>
388392 </li>
389-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
393+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
394+<a href="/files/releases" tabindex="-1">
395+<span class="lbl">releases</span>
396+<span class="k">section</span>
397+</a>
398+</li>
399+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
390400 <a href="/files/actions" tabindex="-1">
391401 <span class="lbl">actions</span>
392402 <span class="k">section</span>
393403 </a>
394404 </li>
395-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
405+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
396406 <a href="/files/tree/feature/x" tabindex="-1">
397407 <span class="lbl">feature/x</span>
398408 <span class="k">branch</span>
399409 </a>
400410 </li>
401-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
411+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
402412 <a href="/files/tree/main" tabindex="-1">
403413 <span class="lbl">main</span>
404414 <span class="k">branch</span>
405415 </a>
406416 </li>
407-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
417+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
408418 <a href="/files/tree/v1.0" tabindex="-1">
409419 <span class="lbl">v1.0</span>
410420 <span class="k">tag</span>
411421 </a>
412422 </li>
413-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
423+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
414424 <a href="#" data-fire="keys" tabindex="-1">
415425 <span class="lbl">turn single-key shortcuts off</span>
416426 <span class="k">action</span>
M internal/app/web.go +1 -0
@@ -57,6 +57,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5757 SigningKey: signing,
5858 Runs: f.CI,
5959 Artifacts: artifacts,
60+ Releases: f.Releases,
6061 Events: f.EventLog,
6162 Mirrors: f.Mirrors,
6263 Owner: cfg.Owner,
A internal/app/web_release_test.go +111 -0
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "io"
9+ "net/http"
10+ "strconv"
11+ "strings"
12+ "testing"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/usecase"
20+)
21+
22+// Releases and web use the production forge wiring.
23+func TestWebRelease(t *testing.T) {
24+ h := newHarness(t, harnessOpts{})
25+ h.webRepo("demo")
26+ blobs, releases := h.forge.Blobs, h.forge.Releases
27+ repo := h.name("demo")
28+ rel, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v0.2.0", Title: "Demo release", Notes: "## Install\n\n**Download** the binary. <script>alert(1)</script>"})
29+ require.NoError(t, err)
30+ payload := "binary release data\x00\xff\n"
31+ asset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", ContentType: "application/octet-stream", Size: int64(len(payload))}, strings.NewReader(payload))
32+ require.NoError(t, err)
33+ draft, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "draft", Title: "Hidden draft", Draft: true})
34+ require.NoError(t, err)
35+ draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))
36+ require.NoError(t, err)
37+ ts := h.srv
38+ get := func(method, path, byteRange string) (int, http.Header, string) {
39+ t.Helper()
40+ req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)
41+ require.NoError(t, err)
42+ if byteRange != "" {
43+ req.Header.Set("Range", byteRange)
44+ }
45+ res, err := ts.Client().Do(req)
46+ require.NoError(t, err)
47+ defer func() { _ = res.Body.Close() }()
48+ body, err := io.ReadAll(res.Body)
49+ require.NoError(t, err)
50+ return res.StatusCode, res.Header, string(body)
51+ }
52+ status, _, body := get("GET", "/demo/releases", "")
53+ require.Equal(t, 200, status)
54+ assert.Contains(t, body, "Demo release")
55+ assert.NotContains(t, body, "Hidden draft")
56+ status, _, body = get("GET", "/demo/releases/tag/v0.2.0", "")
57+ require.Equal(t, 200, status)
58+ assert.Contains(t, body, "<strong>Download</strong>")
59+ assert.NotContains(t, body, "<script>alert")
60+ assert.Contains(t, body, "demo.bin")
61+ assert.Contains(t, body, "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10))
62+ for _, path := range []string{"/demo/releases/download/v0.2.0/demo.bin", "/demo/releases/latest/download/demo.bin", "/demo/releases/assets/" + strconv.FormatInt(asset.ID, 10)} {
63+ status, headers, body := get("GET", path, "")
64+ require.Equal(t, 200, status)
65+ assert.Equal(t, payload, body)
66+ assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
67+ assert.Equal(t, "bytes", headers.Get("Accept-Ranges"))
68+ assert.Contains(t, headers.Get("Content-Disposition"), "attachment")
69+ status, headers, body = get("GET", path, "bytes=2-6")
70+ require.Equal(t, 206, status)
71+ assert.Equal(t, payload[2:7], body)
72+ assert.Equal(t, "bytes 2-6/"+strconv.Itoa(len(payload)), headers.Get("Content-Range"))
73+ assert.Equal(t, "5", headers.Get("Content-Length"))
74+ status, headers, body = get("HEAD", path, "")
75+ require.Equal(t, 200, status)
76+ assert.Empty(t, body)
77+ assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
78+ }
79+ for _, path := range []string{"/demo/releases/tag/draft", "/demo/releases/download/draft/hidden.bin", "/demo/releases/assets/" + strconv.FormatInt(draftAsset.ID, 10)} {
80+ status, _, _ := get("GET", path, "")
81+ assert.Equal(t, 404, status)
82+ }
83+ // Slash-tag page and asset-id download remain unambiguous.
84+ slash, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "series/v1", TargetCommitish: "main", Title: "Slash tag"})
85+ require.NoError(t, err)
86+ status, _, body = get("GET", "/demo/releases/tag/series/v1", "")
87+ assert.Equal(t, 200, status)
88+ assert.Contains(t, body, slash.Title)
89+ // The browser_download_url of the REST API for a slash tag is served by
90+ // the web route.
91+ slashAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: slash.ID, Name: "s.bin", Size: 5}, strings.NewReader("slash"))
92+ require.NoError(t, err)
93+ gh := usecase.NewReleasesGitHub(releases, "owner", ts.URL)
94+ raw, err := gh.GetAsset(h.ctx, domain.Root(), "owner", "demo", slashAsset.ID)
95+ require.NoError(t, err)
96+ var obj struct {
97+ URL string `json:"browser_download_url"`
98+ }
99+ require.NoError(t, json.Unmarshal(raw, &obj))
100+ require.Equal(t, ts.URL+"/demo/releases/download/series%2Fv1/s.bin", obj.URL)
101+ status, _, body = get("GET", strings.TrimPrefix(obj.URL, ts.URL), "")
102+ assert.Equal(t, 200, status)
103+ assert.Equal(t, "slash", body)
104+ // A missing blob is a 404 page before any binary headers are written.
105+ require.NoError(t, blobs.Delete(context.Background(), asset.BlobKey()))
106+ status, headers, body := get("GET", "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10), "")
107+ assert.Equal(t, 404, status)
108+ assert.Contains(t, headers.Get("Content-Type"), "text/html")
109+ assert.Contains(t, body, "no such page")
110+ assert.Empty(t, headers.Get("Content-Disposition"))
111+}
M internal/boundary/boundary_test.go +14 -2
@@ -35,6 +35,7 @@ const (
3535 layerUsecase packageLayer = "usecase"
3636 layerAdapter packageLayer = "adapter"
3737 layerClientLimit packageLayer = "clientlimit"
38+ layerHTTPRange packageLayer = "httprange"
3839 layerConfig packageLayer = "config"
3940 layerPorts packageLayer = "ports"
4041 layerDomain packageLayer = "domain"
@@ -55,13 +56,16 @@ const (
5556 // each other, and keeps the web adapter off the generated API code: like
5657 // every other adapter it reads and writes through the ports (ADR 0020).
5758 // - internal/clientlimit is infrastructure shared by the HTTP adapters.
59+// - internal/httprange is infrastructure shared by the HTTP adapters that
60+// serve byte ranges; it imports nothing of the module.
5861 // - internal/config parses the environment into plain values.
5962 var productionDependencies = map[packageLayer]map[packageLayer]bool{
6063 layerCommand: {layerApp: true},
61- layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
64+ layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
6265 layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true},
63- layerAdapter: {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true},
66+ layerAdapter: {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerPorts: true, layerDomain: true, layerGenerated: true},
6467 layerClientLimit: {layerPorts: true},
68+ layerHTTPRange: {},
6569 layerConfig: {},
6670 layerPorts: {layerDomain: true},
6771 layerDomain: {layerDomain: true},
@@ -149,6 +153,12 @@ func TestImportBoundaryFixtures(t *testing.T) {
149153 {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false},
150154 {"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false},
151155 {"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true},
156+ {"app accepts httprange", p("internal/app"), p("internal/httprange"), false, true},
157+ {"usecase rejects httprange", p("internal/usecase"), p("internal/httprange"), false, false},
158+ {"domain rejects httprange", p("internal/domain"), p("internal/httprange"), false, false},
159+ {"ports reject httprange", p("internal/ports"), p("internal/httprange"), false, false},
160+ {"adapter accepts httprange", p("internal/adapters/web"), p("internal/httprange"), false, true},
161+ {"httprange rejects domain", p("internal/httprange"), p("internal/domain"), false, false},
152162 {"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true},
153163 {"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false},
154164 {"config rejects domain", p("internal/config"), p("internal/domain"), false, false},
@@ -319,6 +329,8 @@ func classifyPackage(path string) (packageLayer, error) {
319329 return layerAdapter, nil
320330 case under(path, p("internal/clientlimit")):
321331 return layerClientLimit, nil
332+ case under(path, p("internal/httprange")):
333+ return layerHTTPRange, nil
322334 case under(path, p("internal/config")):
323335 return layerConfig, nil
324336 case path == p("internal/ports"):
M internal/config/config.go +117 -0
@@ -12,6 +12,7 @@ import (
1212 "net"
1313 "net/netip"
1414 "net/url"
15+ "os"
1516 "path/filepath"
1617 "strconv"
1718 "strings"
@@ -56,6 +57,16 @@ const (
5657 EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION"
5758 EnvCIArtifactQuota = "ERMITE_CI_ARTIFACT_QUOTA"
5859 EnvCIArtifactMax = "ERMITE_CI_ARTIFACT_MAX"
60+ EnvS3Bucket = "ERMITE_S3_BUCKET"
61+ EnvS3Endpoint = "ERMITE_S3_ENDPOINT"
62+ EnvS3Region = "ERMITE_S3_REGION"
63+ EnvS3AccessKeyID = "ERMITE_S3_ACCESS_KEY_ID"
64+ EnvS3SecretAccessKey = "ERMITE_S3_SECRET_ACCESS_KEY"
65+ EnvS3ForcePathStyle = "ERMITE_S3_FORCE_PATH_STYLE"
66+ EnvS3Prefix = "ERMITE_S3_PREFIX"
67+ EnvReleaseAssetMax = "ERMITE_RELEASE_ASSET_MAX"
68+ EnvReleaseAssetsMax = "ERMITE_RELEASE_ASSETS_MAX"
69+ EnvReleaseQuota = "ERMITE_RELEASE_QUOTA"
5970 )
6071
6172 // SecretsKeyLen is the length of the secrets key in bytes (AES-256).
@@ -140,6 +151,12 @@ type Config struct {
140151 CIArtifactRetention time.Duration
141152 CIArtifactQuota int64
142153 CIArtifactMax int64
154+ // S3Bucket selects S3; empty selects local disk. Credentials are secrets.
155+ S3Bucket, S3Endpoint, S3Region, S3AccessKeyID, S3SecretAccessKey, S3Prefix string
156+ S3ForcePathStyle bool
157+ // Zero release limits leave defaults to the release use case.
158+ ReleaseAssetMax, ReleaseQuota int64
159+ ReleaseAssetsMax int
143160 }
144161
145162 // Error lists every configuration problem found, in variable order.
@@ -167,6 +184,7 @@ func Load(get func(string) string) (Config, error) {
167184 bad := func(key, format string, args ...any) {
168185 problems = append(problems, key+": "+fmt.Sprintf(format, args...))
169186 }
187+ get = resolveFiles(get, &problems)
170188 var c Config
171189
172190 c.Addr = get(EnvAddr)
@@ -342,12 +360,111 @@ func Load(get func(string) string) (Config, error) {
342360 }
343361 }
344362
363+ loadStorage(get, &c, &problems)
345364 if len(problems) > 0 {
346365 return Config{}, &Error{Problems: problems}
347366 }
348367 return c, nil
349368 }
350369
370+// resolveFiles resolves only the four supported secrets, once at startup.
371+// Neither file paths, file errors nor secret contents enter diagnostics.
372+func resolveFiles(get func(string) string, problems *[]string) func(string) string {
373+ values := make(map[string]string)
374+ for _, key := range []string{EnvAdminToken, EnvSecretsKey, EnvS3AccessKeyID, EnvS3SecretAccessKey} {
375+ value, file := get(key), get(key+"_FILE")
376+ if value != "" && file != "" {
377+ *problems = append(*problems, key+": cannot set both variable and _FILE")
378+ } else if file != "" {
379+ data, err := os.ReadFile(file)
380+ if err != nil {
381+ *problems = append(*problems, key+"_FILE: cannot read secret file")
382+ } else {
383+ value = strings.TrimRight(string(data), "\r\n")
384+ if value == "" {
385+ *problems = append(*problems, key+"_FILE: secret file is empty")
386+ }
387+ }
388+ }
389+ values[key] = value
390+ }
391+ return func(key string) string {
392+ if value, ok := values[key]; ok {
393+ return value
394+ }
395+ return get(key)
396+ }
397+}
398+
399+func loadStorage(get func(string) string, c *Config, problems *[]string) {
400+ bad := func(key, message string) { *problems = append(*problems, key+": "+message) }
401+ c.S3Bucket = get(EnvS3Bucket)
402+ c.S3Endpoint = get(EnvS3Endpoint)
403+ c.S3Region = get(EnvS3Region)
404+ c.S3AccessKeyID = get(EnvS3AccessKeyID)
405+ c.S3SecretAccessKey = get(EnvS3SecretAccessKey)
406+ c.S3Prefix = strings.Trim(get(EnvS3Prefix), "/")
407+ if c.S3Prefix != "" {
408+ valid := len(c.S3Prefix) <= 512
409+ for _, part := range strings.Split(c.S3Prefix, "/") {
410+ if part == "" || part == "." || part == ".." {
411+ valid = false
412+ }
413+ }
414+ for _, r := range c.S3Prefix {
415+ if r < 0x21 || r > 0x7e || r == '\\' {
416+ valid = false
417+ }
418+ }
419+ if !valid {
420+ bad(EnvS3Prefix, "must be an ASCII key prefix without empty, dot or dot-dot segments")
421+ }
422+ c.S3Prefix += "/"
423+ }
424+ if c.S3Endpoint != "" {
425+ if endpoint, err := parseURL(c.S3Endpoint, false); err != nil {
426+ bad(EnvS3Endpoint, "must be an http(s) URL without credentials, path, query or fragment")
427+ } else {
428+ c.S3Endpoint = endpoint
429+ }
430+ }
431+ switch get(EnvS3ForcePathStyle) {
432+ case "", "0", "false":
433+ case "1", "true":
434+ c.S3ForcePathStyle = true
435+ default:
436+ bad(EnvS3ForcePathStyle, "must be 0, 1, false or true")
437+ }
438+ if c.S3Bucket != "" {
439+ for _, v := range []struct{ key, value string }{{EnvS3Region, c.S3Region}, {EnvS3AccessKeyID, c.S3AccessKeyID}, {EnvS3SecretAccessKey, c.S3SecretAccessKey}} {
440+ if strings.TrimSpace(v.value) == "" {
441+ bad(v.key, "required when ERMITE_S3_BUCKET is set")
442+ }
443+ }
444+ }
445+ for _, v := range []struct {
446+ key string
447+ dst *int64
448+ }{{EnvReleaseAssetMax, &c.ReleaseAssetMax}, {EnvReleaseQuota, &c.ReleaseQuota}} {
449+ if raw := strings.TrimSpace(get(v.key)); raw != "" {
450+ n, err := parseSize(raw)
451+ if err != nil {
452+ bad(v.key, err.Error())
453+ } else {
454+ *v.dst = n
455+ }
456+ }
457+ }
458+ if raw := get(EnvReleaseAssetsMax); raw != "" {
459+ n, err := strconv.Atoi(raw)
460+ if err != nil || n < 1 {
461+ bad(EnvReleaseAssetsMax, "must be a positive integer")
462+ } else {
463+ c.ReleaseAssetsMax = n
464+ }
465+ }
466+}
467+
351468 // sizeUnits are the suffixes of parseSize, longest first.
352469 var sizeUnits = []struct {
353470 suffix string
A internal/config/storage_test.go +111 -0
@@ -0,0 +1,111 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package config_test
3+
4+import (
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "bnema.dev/ermite/internal/config"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func s3Env() map[string]string {
18+ return map[string]string{
19+ config.EnvS3Bucket: "bucket", config.EnvS3Region: "us-east-1",
20+ config.EnvS3AccessKeyID: "private-id", config.EnvS3SecretAccessKey: "private-secret",
21+ }
22+}
23+func TestStorageConfig(t *testing.T) {
24+ c, err := config.Load(lookup(valid(nil)))
25+ require.NoError(t, err)
26+ assert.Empty(t, c.S3Bucket)
27+ env := s3Env()
28+ env[config.EnvS3Endpoint] = "http://localhost:9000/"
29+ env[config.EnvS3Prefix] = "/forge/blobs/"
30+ env[config.EnvS3ForcePathStyle] = "1"
31+ env[config.EnvReleaseAssetMax] = "512MiB"
32+ env[config.EnvReleaseQuota] = "10GiB"
33+ env[config.EnvReleaseAssetsMax] = "7"
34+ c, err = config.Load(lookup(valid(env)))
35+ require.NoError(t, err)
36+ assert.Equal(t, "forge/blobs/", c.S3Prefix)
37+ assert.Equal(t, "http://localhost:9000", c.S3Endpoint)
38+ assert.True(t, c.S3ForcePathStyle)
39+ assert.EqualValues(t, 512<<20, c.ReleaseAssetMax)
40+ assert.EqualValues(t, 10<<30, c.ReleaseQuota)
41+ assert.Equal(t, 7, c.ReleaseAssetsMax)
42+ for _, key := range []string{config.EnvS3Region, config.EnvS3AccessKeyID, config.EnvS3SecretAccessKey} {
43+ t.Run(key, func(t *testing.T) {
44+ env := s3Env()
45+ env[key] = ""
46+ _, err := config.Load(lookup(valid(env)))
47+ require.Error(t, err)
48+ assert.Contains(t, err.Error(), key)
49+ assert.NotContains(t, err.Error(), "private-")
50+ })
51+ }
52+ for key, value := range map[string]string{config.EnvS3Endpoint: "https://private-id:private-secret@example.org", config.EnvS3ForcePathStyle: "yes", config.EnvS3Prefix: "x/../y", config.EnvReleaseAssetMax: "0", config.EnvReleaseQuota: "-1", config.EnvReleaseAssetsMax: "0"} {
53+ t.Run(key+" invalid", func(t *testing.T) {
54+ env := s3Env()
55+ env[key] = value
56+ _, err := config.Load(lookup(valid(env)))
57+ require.Error(t, err)
58+ assert.Contains(t, err.Error(), key)
59+ assert.NotContains(t, err.Error(), "private-")
60+ })
61+ }
62+ for _, value := range []string{"0", "1", "false", "true"} {
63+ env := s3Env()
64+ env[config.EnvS3ForcePathStyle] = value
65+ _, err := config.Load(lookup(valid(env)))
66+ require.NoError(t, err)
67+ }
68+}
69+func TestSecretFiles(t *testing.T) {
70+ for _, key := range []string{config.EnvAdminToken, config.EnvSecretsKey, config.EnvS3AccessKeyID, config.EnvS3SecretAccessKey} {
71+ t.Run(key, func(t *testing.T) {
72+ secret := strings.Repeat("s", 32)
73+ if key == config.EnvSecretsKey {
74+ secret = base64.StdEncoding.EncodeToString([]byte(secret))
75+ }
76+ path := filepath.Join(t.TempDir(), "secret")
77+ require.NoError(t, os.WriteFile(path, []byte(secret+"\r\n"), 0600))
78+ env := valid(s3Env())
79+ env[key] = ""
80+ env[key+"_FILE"] = path
81+ c, err := config.Load(lookup(env))
82+ require.NoError(t, err)
83+ switch key {
84+ case config.EnvAdminToken:
85+ assert.Equal(t, sha256.Sum256([]byte(secret)), c.AdminTokenHash)
86+ case config.EnvSecretsKey:
87+ assert.Len(t, c.SecretsKey, 32)
88+ case config.EnvS3AccessKeyID:
89+ assert.Equal(t, secret, c.S3AccessKeyID)
90+ case config.EnvS3SecretAccessKey:
91+ assert.Equal(t, secret, c.S3SecretAccessKey)
92+ }
93+ env[key] = secret
94+ _, err = config.Load(lookup(env))
95+ require.Error(t, err)
96+ assert.Contains(t, err.Error(), "both")
97+ assert.NotContains(t, err.Error(), secret)
98+ env[key] = ""
99+ env[key+"_FILE"] = path + "missing"
100+ _, err = config.Load(lookup(env))
101+ require.Error(t, err)
102+ assert.Contains(t, err.Error(), key+"_FILE")
103+ assert.NotContains(t, err.Error(), path)
104+ require.NoError(t, os.WriteFile(path, []byte("\n"), 0600))
105+ env[key+"_FILE"] = path
106+ _, err = config.Load(lookup(env))
107+ require.Error(t, err)
108+ assert.Contains(t, err.Error(), "empty")
109+ })
110+ }
111+}
M internal/domain/ci_artifact.go +21 -0
@@ -28,6 +28,8 @@ const (
2828 CIArtifactMaxPerRun = 1000
2929 // CIArtifactChunk is the largest piece of data of one message, both ways.
3030 CIArtifactChunk = 1 << 20
31+ // CIArtifactMaxBlockSize bounds the in-memory staging of a toolkit block.
32+ CIArtifactMaxBlockSize = 8 << 20
3133 // CIArtifactMinRetention is the shortest retention of an artifact.
3234 CIArtifactMinRetention = 24 * time.Hour
3335 // CIArtifactPendingMax is how long an upload may stay unfinished before the
@@ -42,6 +44,16 @@ const (
4244 CIArtifactZip = "application/zip"
4345 )
4446
47+// CIArtifactStore identifies the storage of an artifact's contents.
48+type CIArtifactStore string
49+
50+const (
51+ // CIArtifactStoreLegacy is the pre-blob-store layout under ci/artifacts.
52+ CIArtifactStoreLegacy CIArtifactStore = ""
53+ // CIArtifactStoreBlob stores the contents through ports.BlobStore.
54+ CIArtifactStoreBlob CIArtifactStore = "blob"
55+)
56+
4557 // CIArtifactState is where an artifact is in its upload.
4658 type CIArtifactState string
4759
@@ -74,6 +86,7 @@ type CIArtifact struct {
7486 // Digest is "sha256:<hex>" of the content, known from the commit.
7587 Digest string
7688 State CIArtifactState
89+ Store CIArtifactStore
7790 CreatedAt time.Time
7891 ExpiresAt time.Time
7992 // Charged is what the artifact counts against the quota of its
@@ -85,6 +98,14 @@ type CIArtifact struct {
8598 Version uint64
8699 }
87100
101+// BlobKey names the contents by immutable run and artifact ids only.
102+func (a CIArtifact) BlobKey() string {
103+ return fmt.Sprintf("ci/%020d/%020d", a.RunID, a.ID)
104+}
105+
106+// BlockKey names a staged block; file is generated by the use case, not a user.
107+func (a CIArtifact) BlockKey(file string) string { return a.BlobKey() + ".blocks/" + file }
108+
88109 // Final reports whether the artifact is finalized.
89110 func (a CIArtifact) Final() bool { return a.State == CIArtifactFinal }
90111
M internal/domain/ci_artifact_test.go +7 -0
@@ -13,6 +13,13 @@ import (
1313 "bnema.dev/ermite/internal/domain"
1414 )
1515
16+func TestCIArtifactBlobKeys(t *testing.T) {
17+ a := domain.CIArtifact{RunID: 7, ID: 42, Name: "not-used-in-keys"}
18+ assert.Equal(t, "ci/00000000000000000007/00000000000000000042", a.BlobKey())
19+ assert.Equal(t, a.BlobKey()+".blocks/abcd.0123", a.BlockKey("abcd.0123"))
20+ assert.Equal(t, domain.CIArtifactStoreLegacy, a.Store)
21+}
22+
1623 func TestParseArtifactName(t *testing.T) {
1724 for _, ok := range []string{"dist", "my artifact (linux) v1.2", "é", strings.Repeat("é", 256), "..", "a.b-c_d"} {
1825 assert.NoError(t, domain.ParseArtifactName(ok), "%q", ok)
M internal/domain/event.go +44 -0
@@ -366,3 +366,47 @@ type MirrorRecovered struct {
366366 }
367367
368368 func (MirrorRecovered) EventKind() string { return "mirror.recovered" }
369+
370+// ReleasePublished records a release lifecycle change.
371+type ReleasePublished struct {
372+ Release Release
373+ At time.Time
374+ Visibility Visibility
375+ CreatedAt time.Time
376+}
377+
378+// EventKind implements Event.
379+func (ReleasePublished) EventKind() string { return "release.published" }
380+
381+// ReleaseUpdated records a release lifecycle change.
382+type ReleaseUpdated struct {
383+ Release Release
384+ At time.Time
385+ Visibility Visibility
386+ CreatedAt time.Time
387+}
388+
389+// EventKind implements Event.
390+func (ReleaseUpdated) EventKind() string { return "release.updated" }
391+
392+// ReleaseDeleted records a release lifecycle change.
393+type ReleaseDeleted struct {
394+ Release Release
395+ At time.Time
396+ Visibility Visibility
397+ CreatedAt time.Time
398+}
399+
400+// EventKind implements Event.
401+func (ReleaseDeleted) EventKind() string { return "release.deleted" }
402+
403+// ReleaseUnpublished records a release lifecycle change.
404+type ReleaseUnpublished struct {
405+ Release Release
406+ At time.Time
407+ Visibility Visibility
408+ CreatedAt time.Time
409+}
410+
411+// EventKind implements Event.
412+func (ReleaseUnpublished) EventKind() string { return "release.unpublished" }
A internal/domain/release.go +165 -0
@@ -0,0 +1,165 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "mime"
8+ "strings"
9+ "time"
10+ "unicode"
11+ "unicode/utf8"
12+
13+ "golang.org/x/text/unicode/norm"
14+)
15+
16+// Release binds release notes and assets to a repository tag.
17+type Release struct {
18+ ID int64
19+ Repo RepoName
20+ Tag, Title, Notes string
21+ Draft, Prerelease, MakeLatest bool
22+ // TargetCommitish is the commit at publication, empty for an unresolved draft.
23+ TargetCommitish string
24+ Author string
25+ CreatedAt, PublishedAt, UpdatedAt time.Time
26+ Version uint64
27+}
28+
29+// ReleaseAssetState distinguishes reserved uploads from visible assets.
30+type ReleaseAssetState string
31+
32+const (
33+ ReleaseAssetUploading ReleaseAssetState = "uploading"
34+ ReleaseAssetUploaded ReleaseAssetState = "uploaded"
35+)
36+
37+// ReleaseAsset is metadata only; its contents live in the blob store.
38+type ReleaseAsset struct {
39+ ID, ReleaseID int64
40+ Repo RepoName
41+ Name, Label, ContentType string
42+ Size int64
43+ Digest string
44+ State ReleaseAssetState
45+ Uploader string
46+ // Charged counts bytes reserved against repository quota.
47+ Charged int64
48+ // MultipartID identifies an unfinished upload for cleanup.
49+ MultipartID string
50+ CreatedAt, UpdatedAt time.Time
51+ Version uint64
52+}
53+
54+// BlobKey contains only a globally allocated identifier.
55+func (a ReleaseAsset) BlobKey() string { return fmt.Sprintf("release/%020d", a.ID) }
56+
57+const (
58+ ReleaseMaxTitleLen = 256
59+ ReleaseMaxNotesLen = 125_000
60+ ReleaseAssetMaxNameLen = 255
61+ ReleaseAssetMaxLabelLen = 255
62+ ReleaseAssetPendingMax = 24 * time.Hour
63+ DefaultReleaseAssetMax = 2 << 30
64+ DefaultReleaseAssetsMax = 1000
65+ DefaultReleaseQuota = 20 << 30
66+)
67+
68+// ParseReleaseTag validates a short tag using Git reference-name rules.
69+func ParseReleaseTag(s string) (string, error) {
70+ if s == "" {
71+ return "", fmt.Errorf("%w: empty tag name", ErrInvalid)
72+ }
73+ if strings.HasPrefix(s, "refs/") {
74+ return "", fmt.Errorf("%w: %q: give the short tag name", ErrInvalid, s)
75+ }
76+ if reason := refNameProblem(s); reason != "" {
77+ return "", fmt.Errorf("%w: %q: %s", ErrInvalid, s, reason)
78+ }
79+ return s, nil
80+}
81+
82+func cleanReleaseText(s string, limit int, multiline bool) (string, error) {
83+ if !utf8.ValidString(s) {
84+ return "", fmt.Errorf("%w: invalid UTF-8", ErrInvalid)
85+ }
86+ s = norm.NFC.String(s)
87+ s = strings.Map(func(r rune) rune {
88+ if multiline && (r == '\n' || r == '\t' || r == '\r') {
89+ return r
90+ }
91+ if unicode.IsControl(r) || (!multiline && isBidiControl(r)) {
92+ return -1
93+ }
94+ return r
95+ }, s)
96+ if utf8.RuneCountInString(s) > limit {
97+ return "", fmt.Errorf("%w: text exceeds %d characters", ErrInvalid, limit)
98+ }
99+ return s, nil
100+}
101+
102+// ParseReleaseTitle normalizes and cleans an optional title.
103+func ParseReleaseTitle(s string) (string, error) {
104+ return cleanReleaseText(s, ReleaseMaxTitleLen, false)
105+}
106+
107+// ParseReleaseNotes validates and cleans multiline notes.
108+func ParseReleaseNotes(s string) (string, error) {
109+ return cleanReleaseText(s, ReleaseMaxNotesLen, true)
110+}
111+
112+// ParseReleaseAssetLabel normalizes and cleans an optional label.
113+func ParseReleaseAssetLabel(s string) (string, error) {
114+ return cleanReleaseText(s, ReleaseAssetMaxLabelLen, false)
115+}
116+
117+// ParseReleaseAssetName validates a single filename without rewriting it.
118+func ParseReleaseAssetName(s string) (string, error) {
119+ if !utf8.ValidString(s) || s == "" || s == "." || s == ".." || utf8.RuneCountInString(s) > ReleaseAssetMaxNameLen || strings.ContainsAny(s, "/\\") {
120+ return "", fmt.Errorf("%w: invalid asset name", ErrInvalid)
121+ }
122+ for _, r := range s {
123+ if unicode.IsControl(r) {
124+ return "", fmt.Errorf("%w: control character in asset name", ErrInvalid)
125+ }
126+ }
127+ return s, nil
128+}
129+
130+// ParseReleaseContentType validates a printable ASCII media type.
131+func ParseReleaseContentType(s string) (string, error) {
132+ if s == "" {
133+ return "application/octet-stream", nil
134+ }
135+ if len(s) > 255 {
136+ return "", fmt.Errorf("%w: media type too long", ErrInvalid)
137+ }
138+ for _, r := range s {
139+ if r < 32 || r > 126 {
140+ return "", fmt.Errorf("%w: invalid media type", ErrInvalid)
141+ }
142+ }
143+ if _, _, err := mime.ParseMediaType(s); err != nil {
144+ return "", fmt.Errorf("%w: media type: %v", ErrInvalid, err)
145+ }
146+ return s, nil
147+}
148+
149+// Published reports whether the release is public to repository readers.
150+func (r Release) Published() bool { return !r.Draft }
151+
152+// LatestRelease selects a pin first, then the newest stable published release.
153+func LatestRelease(rs []Release) (Release, bool) {
154+ var best Release
155+ ok := false
156+ for _, r := range rs {
157+ if r.Draft || r.Prerelease {
158+ continue
159+ }
160+ if !ok || (r.MakeLatest && !best.MakeLatest) || (r.MakeLatest == best.MakeLatest && (r.CreatedAt.After(best.CreatedAt) || (r.CreatedAt.Equal(best.CreatedAt) && r.ID > best.ID))) {
161+ best, ok = r, true
162+ }
163+ }
164+ return best, ok
165+}
A internal/domain/release_test.go +59 -0
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package domain_test
3+
4+import (
5+ "bnema.dev/ermite/internal/domain"
6+ "github.com/stretchr/testify/require"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestReleaseParsers(t *testing.T) {
13+ for _, tc := range []struct {
14+ parse func(string) (string, error)
15+ good, bad []string
16+ }{
17+ {domain.ParseReleaseTag, []string{"v1.0", "nightly/x"}, []string{"", "refs/tags/v1", "..", "a..b", "a b"}},
18+ {domain.ParseReleaseTitle, []string{"", strings.Repeat("é", 256)}, []string{strings.Repeat("x", 257), "\xff"}},
19+ {domain.ParseReleaseNotes, []string{"", "a\n\t\rb", strings.Repeat("x", 125000)}, []string{"\xff", strings.Repeat("x", 125001)}},
20+ {domain.ParseReleaseAssetName, []string{"a b", "é", strings.Repeat("x", 255)}, []string{"", ".", "..", "a/b", "a\\b", "a\x00", "\xff", strings.Repeat("x", 256)}},
21+ {domain.ParseReleaseAssetLabel, []string{"", strings.Repeat("x", 255)}, []string{"\xff", strings.Repeat("x", 256)}},
22+ {domain.ParseReleaseContentType, []string{"", "text/plain; charset=utf-8"}, []string{"bad/type/type", "text/plain\n", strings.Repeat("x", 256), "é"}},
23+ } {
24+ for _, s := range tc.good {
25+ _, err := tc.parse(s)
26+ require.NoError(t, err, s)
27+ }
28+ for _, s := range tc.bad {
29+ _, err := tc.parse(s)
30+ require.ErrorIs(t, err, domain.ErrInvalid, s)
31+ }
32+ }
33+ title, err := domain.ParseReleaseTitle("e\u0301\x00\u202e")
34+ require.NoError(t, err)
35+ require.Equal(t, "é", title)
36+ notes, err := domain.ParseReleaseNotes("a\x00\n\t\r")
37+ require.NoError(t, err)
38+ require.Equal(t, "a\n\t\r", notes)
39+ require.Equal(t, "release/00000000000000000001", (domain.ReleaseAsset{ID: 1}).BlobKey())
40+ scope, err := domain.ParseScope("release:write")
41+ require.NoError(t, err)
42+ require.Equal(t, domain.ScopeReleaseWrite, scope)
43+}
44+func TestLatestRelease(t *testing.T) {
45+ now := time.Now()
46+ rs := []domain.Release{{ID: 1, CreatedAt: now, MakeLatest: true}, {ID: 2, CreatedAt: now.Add(time.Hour)}, {ID: 3, Draft: true, MakeLatest: true}, {ID: 4, Prerelease: true, MakeLatest: true}}
47+ r, ok := domain.LatestRelease(rs)
48+ require.True(t, ok)
49+ require.EqualValues(t, 1, r.ID)
50+ rs[0].MakeLatest = false
51+ r, ok = domain.LatestRelease(rs)
52+ require.True(t, ok)
53+ require.EqualValues(t, 2, r.ID)
54+ rs[0].CreatedAt = rs[1].CreatedAt
55+ r, _ = domain.LatestRelease(rs)
56+ require.EqualValues(t, 2, r.ID)
57+ _, ok = domain.LatestRelease(rs[2:])
58+ require.False(t, ok)
59+}
M internal/domain/token.go +4 -2
@@ -45,16 +45,18 @@ const (
4545 // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
4646 // is not part of it: it stays with the owner.
4747 ScopeIssueWrite Scope = "issue:write"
48+ // ScopeReleaseWrite creates, edits and deletes releases and their assets.
49+ ScopeReleaseWrite Scope = "release:write"
4850 )
4951
50-// AllScopes returns the twelve scopes in a stable order.
52+// AllScopes returns the thirteen scopes in a stable order.
5153 func AllScopes() []Scope {
5254 return []Scope{
5355 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
5456 ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview,
5557 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
5658 ScopeTokenAdmin,
57- ScopeIssueWrite,
59+ ScopeIssueWrite, ScopeReleaseWrite,
5860 }
5961 }
6062
M internal/domain/token_test.go +5 -1
@@ -16,7 +16,11 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 12)
19+ assert.Len(t, all, 13)
20+ assert.Equal(t, domain.ScopeMRForceMerge, all[5])
21+ assert.Equal(t, domain.ScopeReleaseWrite, all[len(all)-1])
22+ assert.Contains(t, all, domain.ScopeReleaseWrite)
23+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:len(all)-1]}).HasOwnerRights(), "missing only release:write")
2024 assert.Contains(t, all, domain.ScopeIssueWrite)
2125 got, err := domain.ParseScope("issue:write")
2226 require.NoError(t, err)
A internal/httprange/httprange.go +77 -0
@@ -0,0 +1,77 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package httprange selects the byte range of a download from the Range and
4+// If-Range headers of a request. The HTTP adapters that serve release assets
5+// share it, so they answer the same way.
6+package httprange
7+
8+import (
9+ "errors"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+)
14+
15+// ErrUnsatisfiable means the request asked for a range the content cannot give.
16+// The caller answers 416 with "Content-Range: bytes */<size>".
17+var ErrUnsatisfiable = errors.New("httprange: unsatisfiable byte range")
18+
19+// Select returns the part of a size-byte body to send.
20+//
21+// Without a usable Range it returns the whole body (0, size, false, nil):
22+// - the method is not GET (HEAD answers with the headers of the full body);
23+// - there is no Range header, its unit is not "bytes", or it lists several
24+// ranges (ADR 0034: multipart ranges are not served);
25+// - If-Range is present and is not exactly etag. The comparison is strong:
26+// a weak validator or an HTTP date never matches.
27+//
28+// A single "bytes=a-b", "bytes=a-" or "bytes=-n" gives (offset, length, true,
29+// nil); an end past the content is cut to it. A malformed range, a start at or
30+// past size, a suffix of zero, or any range of an empty body returns
31+// ErrUnsatisfiable.
32+func Select(r *http.Request, size int64, etag string) (offset, length int64, partial bool, err error) {
33+ whole := func() (int64, int64, bool, error) { return 0, size, false, nil }
34+ if r.Method != http.MethodGet {
35+ return whole()
36+ }
37+ value := strings.Join(r.Header.Values("Range"), ",")
38+ if value == "" || strings.Contains(value, ",") || !strings.HasPrefix(value, "bytes=") {
39+ return whole()
40+ }
41+ if ifRange := r.Header.Get("If-Range"); ifRange != "" && (etag == "" || ifRange != etag || strings.HasPrefix(ifRange, "W/")) {
42+ return whole()
43+ }
44+ a, b, ok := strings.Cut(strings.TrimSpace(strings.TrimPrefix(value, "bytes=")), "-")
45+ if !ok || size <= 0 {
46+ return 0, 0, false, ErrUnsatisfiable
47+ }
48+ if a == "" {
49+ n, e := strconv.ParseInt(b, 10, 64)
50+ if e != nil || n <= 0 {
51+ return 0, 0, false, ErrUnsatisfiable
52+ }
53+ n = min(n, size)
54+ return size - n, n, true, nil
55+ }
56+ start, e := strconv.ParseInt(a, 10, 64)
57+ if e != nil || start < 0 || start >= size {
58+ return 0, 0, false, ErrUnsatisfiable
59+ }
60+ end := size - 1
61+ if b != "" {
62+ end, e = strconv.ParseInt(b, 10, 64)
63+ if e != nil || end < start {
64+ return 0, 0, false, ErrUnsatisfiable
65+ }
66+ end = min(end, size-1)
67+ }
68+ return start, end - start + 1, true, nil
69+}
70+
71+// ContentRange is the Content-Range value of a partial answer.
72+func ContentRange(offset, length, size int64) string {
73+ return "bytes " + strconv.FormatInt(offset, 10) + "-" + strconv.FormatInt(offset+length-1, 10) + "/" + strconv.FormatInt(size, 10)
74+}
75+
76+// Unsatisfied is the Content-Range value of a 416 answer.
77+func Unsatisfied(size int64) string { return "bytes */" + strconv.FormatInt(size, 10) }
A internal/httprange/httprange_test.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httprange_test
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/httprange"
13+)
14+
15+func TestSelect(t *testing.T) {
16+ const etag = `"sha256-abc"`
17+ for _, tc := range []struct {
18+ name string
19+ method, rng, ifRange string
20+ size int64
21+ offset, length int64
22+ partial bool
23+ err error
24+ }{
25+ {name: "no range", size: 10, length: 10},
26+ {name: "closed", rng: "bytes=2-4", size: 10, offset: 2, length: 3, partial: true},
27+ {name: "open", rng: "bytes=7-", size: 10, offset: 7, length: 3, partial: true},
28+ {name: "suffix", rng: "bytes=-4", size: 10, offset: 6, length: 4, partial: true},
29+ {name: "suffix longer than body", rng: "bytes=-40", size: 10, offset: 0, length: 10, partial: true},
30+ {name: "end cut to size", rng: "bytes=8-99", size: 10, offset: 8, length: 2, partial: true},
31+ {name: "spaces", rng: "bytes= 1-2 ", size: 10, offset: 1, length: 2, partial: true},
32+ {name: "start past end", rng: "bytes=10-", size: 10, err: httprange.ErrUnsatisfiable},
33+ {name: "end before start", rng: "bytes=5-4", size: 10, err: httprange.ErrUnsatisfiable},
34+ {name: "zero suffix", rng: "bytes=-0", size: 10, err: httprange.ErrUnsatisfiable},
35+ {name: "garbage", rng: "bytes=a-b", size: 10, err: httprange.ErrUnsatisfiable},
36+ {name: "no dash", rng: "bytes=5", size: 10, err: httprange.ErrUnsatisfiable},
37+ {name: "empty body", rng: "bytes=0-", size: 0, err: httprange.ErrUnsatisfiable},
38+ {name: "multiple ignored", rng: "bytes=0-1,3-4", size: 10, length: 10},
39+ {name: "other unit ignored", rng: "items=0-1", size: 10, length: 10},
40+ {name: "head ignores range", method: http.MethodHead, rng: "bytes=2-4", size: 10, length: 10},
41+ {name: "head ignores bad range", method: http.MethodHead, rng: "bytes=99-", size: 10, length: 10},
42+ {name: "matching if-range", rng: "bytes=2-4", ifRange: etag, size: 10, offset: 2, length: 3, partial: true},
43+ {name: "stale if-range", rng: "bytes=2-4", ifRange: `"other"`, size: 10, length: 10},
44+ {name: "date if-range", rng: "bytes=2-4", ifRange: "Wed, 21 Oct 2015 07:28:00 GMT", size: 10, length: 10},
45+ {name: "weak if-range", rng: "bytes=2-4", ifRange: "W/" + etag, size: 10, length: 10},
46+ {name: "stale if-range with bad range", rng: "bytes=99-", ifRange: `"other"`, size: 10, length: 10},
47+ } {
48+ t.Run(tc.name, func(t *testing.T) {
49+ method := tc.method
50+ if method == "" {
51+ method = http.MethodGet
52+ }
53+ r := httptest.NewRequest(method, "/x", nil)
54+ if tc.rng != "" {
55+ r.Header.Set("Range", tc.rng)
56+ }
57+ if tc.ifRange != "" {
58+ r.Header.Set("If-Range", tc.ifRange)
59+ }
60+ offset, length, partial, err := httprange.Select(r, tc.size, etag)
61+ require.ErrorIs(t, err, tc.err)
62+ require.Equal(t, tc.offset, offset)
63+ require.Equal(t, tc.length, length)
64+ require.Equal(t, tc.partial, partial)
65+ })
66+ }
67+}
68+
69+func TestContentRange(t *testing.T) {
70+ require.Equal(t, "bytes 2-4/10", httprange.ContentRange(2, 3, 10))
71+ require.Equal(t, "bytes */10", httprange.Unsatisfied(10))
72+}
A internal/ports/blob.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+ "io"
8+ "strings"
9+ "time"
10+)
11+
12+// BlobStore keeps the contents of release assets and CI artifacts, never
13+// their metadata. Keys are built by the use cases from ids only ("a/b/c",
14+// ASCII, no empty segment, no ".", no ".." segment, no leading "/", ≤ 512
15+// bytes); an adapter rejects any other key with domain.ErrInvalid.
16+// *blobdisk.Store and *blobs3.Store implement it.
17+type BlobStore interface {
18+ Put(ctx context.Context, key string, r io.Reader, size int64) (BlobInfo, error) // size ≥ 0 required; replaces; a failure leaves no object
19+ Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, BlobInfo, error) // length < 0: to the end; offset ≥ size → domain.ErrOutOfRange
20+ Stat(ctx context.Context, key string) (BlobInfo, error)
21+ Delete(ctx context.Context, key string) error // absent: nil
22+ List(ctx context.Context, prefix string, fn func(BlobInfo) error) error // lexicographic; fn error stops and is returned
23+ NewMultipart(ctx context.Context, key string) (MultipartUpload, error)
24+ ListMultipart(ctx context.Context, prefix string, fn func(MultipartInfo) error) error
25+ AbortMultipart(ctx context.Context, key, id string) error // for GC of uploads listed by ListMultipart
26+ Check(ctx context.Context) error // startup probe; never creates anything
27+}
28+type BlobInfo struct {
29+ Key string
30+ Size int64
31+ Digest string
32+ ModTime time.Time
33+} // Digest "sha256:<hex>" from Put, "" from Stat/List/Open on S3
34+type MultipartInfo struct {
35+ Key, ID string
36+ Started time.Time
37+}
38+
39+// MultipartUpload uploads one object in parts of BlobMinPartSize or more
40+// (the last may be smaller), at most BlobMaxParts, numbered from 1 in the
41+// order given to Complete. Abort discards everything; Complete or Abort
42+// must be called exactly once.
43+type MultipartUpload interface {
44+ ID() string
45+ UploadPart(ctx context.Context, n int, r io.Reader, size int64) error
46+ Complete(ctx context.Context) (BlobInfo, error) // parts in number order; Digest "" (the caller hashes)
47+ Abort(ctx context.Context) error
48+}
49+
50+const (
51+ BlobMinPartSize = 5 << 20
52+ BlobPartSize = 8 << 20
53+ BlobMaxParts = 10_000
54+)
55+
56+// ValidBlobKey reports whether key follows the rule above.
57+func ValidBlobKey(key string) bool {
58+ if len(key) == 0 || len(key) > 512 {
59+ return false
60+ }
61+ for _, c := range []byte(key) {
62+ if c > 127 {
63+ return false
64+ }
65+ }
66+ for _, segment := range strings.Split(key, "/") {
67+ if segment == "" || segment == "." || segment == ".." {
68+ return false
69+ }
70+ }
71+ return true
72+}
A internal/ports/blob_test.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package ports_test
3+
4+import (
5+ "strings"
6+ "testing"
7+
8+ "bnema.dev/ermite/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestValidBlobKey(t *testing.T) {
13+ for _, v := range []struct {
14+ key string
15+ ok bool
16+ }{{"ci/00000000000000000001/x", true}, {"a/.tmp-object", true}, {"a.parts/b", true}, {"", false}, {"/a", false}, {"a//b", false}, {"a/../b", false}, {"a/./b", false}, {"é", false}, {strings.Repeat("a", 513), false}} {
17+ t.Run(v.key, func(t *testing.T) { require.Equal(t, v.ok, ports.ValidBlobKey(v.key)) })
18+ }
19+}
M internal/ports/ci_artifact.go +3 -13
@@ -75,24 +75,14 @@ type CIArtifactRef struct {
7575 ID int64
7676 }
7777
78-// CIArtifactFiles keeps the contents of the CI artifacts and their staged
79-// blocks, never in the metadata store. Files are named by ids only.
78+// CIArtifactFiles reads and removes the artifacts recorded before the blob
79+// store (ADR 0034); removed once they have all expired. *ciartifacts.Store
80+// implements it. Files are named by ids only.
8081 type CIArtifactFiles interface {
81- // PutBlock writes src into a new block file of the artifact, synced, and
82- // returns its size. A failure of src or of the write leaves no file.
83- PutBlock(ctx context.Context, ref CIArtifactRef, file string, src io.Reader) (int64, error)
84- // RemoveBlock deletes one block file; a missing one is not an error.
85- RemoveBlock(ctx context.Context, ref CIArtifactRef, file string) error
86- // Assemble concatenates the block files in order into the content of the
87- // artifact, which replaces any earlier one whole, and returns its size and
88- // digest ("sha256:<hex>"). A missing block file is domain.ErrNotFound.
89- Assemble(ctx context.Context, ref CIArtifactRef, files []string) (size int64, digest string, err error)
9082 // Open returns the content from offset, or domain.ErrNotFound.
9183 Open(ctx context.Context, ref CIArtifactRef, offset int64) (io.ReadCloser, error)
9284 // Remove deletes the content and the blocks of the artifact.
9385 Remove(ctx context.Context, ref CIArtifactRef) error
94- // RemoveBlocks deletes the blocks of the artifact.
95- RemoveBlocks(ctx context.Context, ref CIArtifactRef) error
9686 // RemoveRuns deletes the files of the runs.
9787 RemoveRuns(ctx context.Context, runIDs []int64) error
9888 // RunIDs lists the runs that have a directory, ArtifactIDs the artifacts
A internal/ports/mocks/BlobStore.go +644 -0
@@ -0,0 +1,644 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewBlobStore creates a new instance of BlobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewBlobStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *BlobStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &BlobStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// BlobStore is an autogenerated mock type for the BlobStore type
39+type BlobStore struct {
40+ mock.Mock
41+}
42+
43+type BlobStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *BlobStore) EXPECT() *BlobStore_Expecter {
48+ return &BlobStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// AbortMultipart provides a mock function for the type BlobStore
52+func (_mock *BlobStore) AbortMultipart(ctx context.Context, key string, id string) error {
53+ ret := _mock.Called(ctx, key, id)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AbortMultipart")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
61+ r0 = returnFunc(ctx, key, id)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// BlobStore_AbortMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbortMultipart'
69+type BlobStore_AbortMultipart_Call struct {
70+ *mock.Call
71+}
72+
73+// AbortMultipart is a helper method to define mock.On call
74+// - ctx context.Context
75+// - key string
76+// - id string
77+func (_e *BlobStore_Expecter) AbortMultipart(ctx any, key any, id any) *BlobStore_AbortMultipart_Call {
78+ return &BlobStore_AbortMultipart_Call{Call: _e.mock.On("AbortMultipart", ctx, key, id)}
79+}
80+
81+func (_c *BlobStore_AbortMultipart_Call) Run(run func(ctx context.Context, key string, id string)) *BlobStore_AbortMultipart_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ var arg2 string
92+ if args[2] != nil {
93+ arg2 = args[2].(string)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *BlobStore_AbortMultipart_Call) Return(err error) *BlobStore_AbortMultipart_Call {
105+ _c.Call.Return(err)
106+ return _c
107+}
108+
109+func (_c *BlobStore_AbortMultipart_Call) RunAndReturn(run func(ctx context.Context, key string, id string) error) *BlobStore_AbortMultipart_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
114+// Check provides a mock function for the type BlobStore
115+func (_mock *BlobStore) Check(ctx context.Context) error {
116+ ret := _mock.Called(ctx)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for Check")
120+ }
121+
122+ var r0 error
123+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
124+ r0 = returnFunc(ctx)
125+ } else {
126+ r0 = ret.Error(0)
127+ }
128+ return r0
129+}
130+
131+// BlobStore_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
132+type BlobStore_Check_Call struct {
133+ *mock.Call
134+}
135+
136+// Check is a helper method to define mock.On call
137+// - ctx context.Context
138+func (_e *BlobStore_Expecter) Check(ctx any) *BlobStore_Check_Call {
139+ return &BlobStore_Check_Call{Call: _e.mock.On("Check", ctx)}
140+}
141+
142+func (_c *BlobStore_Check_Call) Run(run func(ctx context.Context)) *BlobStore_Check_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ run(
149+ arg0,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *BlobStore_Check_Call) Return(err error) *BlobStore_Check_Call {
156+ _c.Call.Return(err)
157+ return _c
158+}
159+
160+func (_c *BlobStore_Check_Call) RunAndReturn(run func(ctx context.Context) error) *BlobStore_Check_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Delete provides a mock function for the type BlobStore
166+func (_mock *BlobStore) Delete(ctx context.Context, key string) error {
167+ ret := _mock.Called(ctx, key)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Delete")
171+ }
172+
173+ var r0 error
174+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
175+ r0 = returnFunc(ctx, key)
176+ } else {
177+ r0 = ret.Error(0)
178+ }
179+ return r0
180+}
181+
182+// BlobStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
183+type BlobStore_Delete_Call struct {
184+ *mock.Call
185+}
186+
187+// Delete is a helper method to define mock.On call
188+// - ctx context.Context
189+// - key string
190+func (_e *BlobStore_Expecter) Delete(ctx any, key any) *BlobStore_Delete_Call {
191+ return &BlobStore_Delete_Call{Call: _e.mock.On("Delete", ctx, key)}
192+}
193+
194+func (_c *BlobStore_Delete_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Delete_Call {
195+ _c.Call.Run(func(args mock.Arguments) {
196+ var arg0 context.Context
197+ if args[0] != nil {
198+ arg0 = args[0].(context.Context)
199+ }
200+ var arg1 string
201+ if args[1] != nil {
202+ arg1 = args[1].(string)
203+ }
204+ run(
205+ arg0,
206+ arg1,
207+ )
208+ })
209+ return _c
210+}
211+
212+func (_c *BlobStore_Delete_Call) Return(err error) *BlobStore_Delete_Call {
213+ _c.Call.Return(err)
214+ return _c
215+}
216+
217+func (_c *BlobStore_Delete_Call) RunAndReturn(run func(ctx context.Context, key string) error) *BlobStore_Delete_Call {
218+ _c.Call.Return(run)
219+ return _c
220+}
221+
222+// List provides a mock function for the type BlobStore
223+func (_mock *BlobStore) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
224+ ret := _mock.Called(ctx, prefix, fn)
225+
226+ if len(ret) == 0 {
227+ panic("no return value specified for List")
228+ }
229+
230+ var r0 error
231+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.BlobInfo) error) error); ok {
232+ r0 = returnFunc(ctx, prefix, fn)
233+ } else {
234+ r0 = ret.Error(0)
235+ }
236+ return r0
237+}
238+
239+// BlobStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
240+type BlobStore_List_Call struct {
241+ *mock.Call
242+}
243+
244+// List is a helper method to define mock.On call
245+// - ctx context.Context
246+// - prefix string
247+// - fn func(ports.BlobInfo) error
248+func (_e *BlobStore_Expecter) List(ctx any, prefix any, fn any) *BlobStore_List_Call {
249+ return &BlobStore_List_Call{Call: _e.mock.On("List", ctx, prefix, fn)}
250+}
251+
252+func (_c *BlobStore_List_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error)) *BlobStore_List_Call {
253+ _c.Call.Run(func(args mock.Arguments) {
254+ var arg0 context.Context
255+ if args[0] != nil {
256+ arg0 = args[0].(context.Context)
257+ }
258+ var arg1 string
259+ if args[1] != nil {
260+ arg1 = args[1].(string)
261+ }
262+ var arg2 func(ports.BlobInfo) error
263+ if args[2] != nil {
264+ arg2 = args[2].(func(ports.BlobInfo) error)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ arg2,
270+ )
271+ })
272+ return _c
273+}
274+
275+func (_c *BlobStore_List_Call) Return(err error) *BlobStore_List_Call {
276+ _c.Call.Return(err)
277+ return _c
278+}
279+
280+func (_c *BlobStore_List_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error) *BlobStore_List_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
284+
285+// ListMultipart provides a mock function for the type BlobStore
286+func (_mock *BlobStore) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
287+ ret := _mock.Called(ctx, prefix, fn)
288+
289+ if len(ret) == 0 {
290+ panic("no return value specified for ListMultipart")
291+ }
292+
293+ var r0 error
294+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.MultipartInfo) error) error); ok {
295+ r0 = returnFunc(ctx, prefix, fn)
296+ } else {
297+ r0 = ret.Error(0)
298+ }
299+ return r0
300+}
301+
302+// BlobStore_ListMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMultipart'
303+type BlobStore_ListMultipart_Call struct {
304+ *mock.Call
305+}
306+
307+// ListMultipart is a helper method to define mock.On call
308+// - ctx context.Context
309+// - prefix string
310+// - fn func(ports.MultipartInfo) error
311+func (_e *BlobStore_Expecter) ListMultipart(ctx any, prefix any, fn any) *BlobStore_ListMultipart_Call {
312+ return &BlobStore_ListMultipart_Call{Call: _e.mock.On("ListMultipart", ctx, prefix, fn)}
313+}
314+
315+func (_c *BlobStore_ListMultipart_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error)) *BlobStore_ListMultipart_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 context.Context
318+ if args[0] != nil {
319+ arg0 = args[0].(context.Context)
320+ }
321+ var arg1 string
322+ if args[1] != nil {
323+ arg1 = args[1].(string)
324+ }
325+ var arg2 func(ports.MultipartInfo) error
326+ if args[2] != nil {
327+ arg2 = args[2].(func(ports.MultipartInfo) error)
328+ }
329+ run(
330+ arg0,
331+ arg1,
332+ arg2,
333+ )
334+ })
335+ return _c
336+}
337+
338+func (_c *BlobStore_ListMultipart_Call) Return(err error) *BlobStore_ListMultipart_Call {
339+ _c.Call.Return(err)
340+ return _c
341+}
342+
343+func (_c *BlobStore_ListMultipart_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error) *BlobStore_ListMultipart_Call {
344+ _c.Call.Return(run)
345+ return _c
346+}
347+
348+// NewMultipart provides a mock function for the type BlobStore
349+func (_mock *BlobStore) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
350+ ret := _mock.Called(ctx, key)
351+
352+ if len(ret) == 0 {
353+ panic("no return value specified for NewMultipart")
354+ }
355+
356+ var r0 ports.MultipartUpload
357+ var r1 error
358+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.MultipartUpload, error)); ok {
359+ return returnFunc(ctx, key)
360+ }
361+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.MultipartUpload); ok {
362+ r0 = returnFunc(ctx, key)
363+ } else {
364+ if ret.Get(0) != nil {
365+ r0 = ret.Get(0).(ports.MultipartUpload)
366+ }
367+ }
368+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
369+ r1 = returnFunc(ctx, key)
370+ } else {
371+ r1 = ret.Error(1)
372+ }
373+ return r0, r1
374+}
375+
376+// BlobStore_NewMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMultipart'
377+type BlobStore_NewMultipart_Call struct {
378+ *mock.Call
379+}
380+
381+// NewMultipart is a helper method to define mock.On call
382+// - ctx context.Context
383+// - key string
384+func (_e *BlobStore_Expecter) NewMultipart(ctx any, key any) *BlobStore_NewMultipart_Call {
385+ return &BlobStore_NewMultipart_Call{Call: _e.mock.On("NewMultipart", ctx, key)}
386+}
387+
388+func (_c *BlobStore_NewMultipart_Call) Run(run func(ctx context.Context, key string)) *BlobStore_NewMultipart_Call {
389+ _c.Call.Run(func(args mock.Arguments) {
390+ var arg0 context.Context
391+ if args[0] != nil {
392+ arg0 = args[0].(context.Context)
393+ }
394+ var arg1 string
395+ if args[1] != nil {
396+ arg1 = args[1].(string)
397+ }
398+ run(
399+ arg0,
400+ arg1,
401+ )
402+ })
403+ return _c
404+}
405+
406+func (_c *BlobStore_NewMultipart_Call) Return(multipartUpload ports.MultipartUpload, err error) *BlobStore_NewMultipart_Call {
407+ _c.Call.Return(multipartUpload, err)
408+ return _c
409+}
410+
411+func (_c *BlobStore_NewMultipart_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.MultipartUpload, error)) *BlobStore_NewMultipart_Call {
412+ _c.Call.Return(run)
413+ return _c
414+}
415+
416+// Open provides a mock function for the type BlobStore
417+func (_mock *BlobStore) Open(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error) {
418+ ret := _mock.Called(ctx, key, offset, length)
419+
420+ if len(ret) == 0 {
421+ panic("no return value specified for Open")
422+ }
423+
424+ var r0 io.ReadCloser
425+ var r1 ports.BlobInfo
426+ var r2 error
427+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) (io.ReadCloser, ports.BlobInfo, error)); ok {
428+ return returnFunc(ctx, key, offset, length)
429+ }
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) io.ReadCloser); ok {
431+ r0 = returnFunc(ctx, key, offset, length)
432+ } else {
433+ if ret.Get(0) != nil {
434+ r0 = ret.Get(0).(io.ReadCloser)
435+ }
436+ }
437+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int64, int64) ports.BlobInfo); ok {
438+ r1 = returnFunc(ctx, key, offset, length)
439+ } else {
440+ r1 = ret.Get(1).(ports.BlobInfo)
441+ }
442+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, int64, int64) error); ok {
443+ r2 = returnFunc(ctx, key, offset, length)
444+ } else {
445+ r2 = ret.Error(2)
446+ }
447+ return r0, r1, r2
448+}
449+
450+// BlobStore_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
451+type BlobStore_Open_Call struct {
452+ *mock.Call
453+}
454+
455+// Open is a helper method to define mock.On call
456+// - ctx context.Context
457+// - key string
458+// - offset int64
459+// - length int64
460+func (_e *BlobStore_Expecter) Open(ctx any, key any, offset any, length any) *BlobStore_Open_Call {
461+ return &BlobStore_Open_Call{Call: _e.mock.On("Open", ctx, key, offset, length)}
462+}
463+
464+func (_c *BlobStore_Open_Call) Run(run func(ctx context.Context, key string, offset int64, length int64)) *BlobStore_Open_Call {
465+ _c.Call.Run(func(args mock.Arguments) {
466+ var arg0 context.Context
467+ if args[0] != nil {
468+ arg0 = args[0].(context.Context)
469+ }
470+ var arg1 string
471+ if args[1] != nil {
472+ arg1 = args[1].(string)
473+ }
474+ var arg2 int64
475+ if args[2] != nil {
476+ arg2 = args[2].(int64)
477+ }
478+ var arg3 int64
479+ if args[3] != nil {
480+ arg3 = args[3].(int64)
481+ }
482+ run(
483+ arg0,
484+ arg1,
485+ arg2,
486+ arg3,
487+ )
488+ })
489+ return _c
490+}
491+
492+func (_c *BlobStore_Open_Call) Return(readCloser io.ReadCloser, blobInfo ports.BlobInfo, err error) *BlobStore_Open_Call {
493+ _c.Call.Return(readCloser, blobInfo, err)
494+ return _c
495+}
496+
497+func (_c *BlobStore_Open_Call) RunAndReturn(run func(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error)) *BlobStore_Open_Call {
498+ _c.Call.Return(run)
499+ return _c
500+}
501+
502+// Put provides a mock function for the type BlobStore
503+func (_mock *BlobStore) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
504+ ret := _mock.Called(ctx, key, r, size)
505+
506+ if len(ret) == 0 {
507+ panic("no return value specified for Put")
508+ }
509+
510+ var r0 ports.BlobInfo
511+ var r1 error
512+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) (ports.BlobInfo, error)); ok {
513+ return returnFunc(ctx, key, r, size)
514+ }
515+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) ports.BlobInfo); ok {
516+ r0 = returnFunc(ctx, key, r, size)
517+ } else {
518+ r0 = ret.Get(0).(ports.BlobInfo)
519+ }
520+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, io.Reader, int64) error); ok {
521+ r1 = returnFunc(ctx, key, r, size)
522+ } else {
523+ r1 = ret.Error(1)
524+ }
525+ return r0, r1
526+}
527+
528+// BlobStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
529+type BlobStore_Put_Call struct {
530+ *mock.Call
531+}
532+
533+// Put is a helper method to define mock.On call
534+// - ctx context.Context
535+// - key string
536+// - r io.Reader
537+// - size int64
538+func (_e *BlobStore_Expecter) Put(ctx any, key any, r any, size any) *BlobStore_Put_Call {
539+ return &BlobStore_Put_Call{Call: _e.mock.On("Put", ctx, key, r, size)}
540+}
541+
542+func (_c *BlobStore_Put_Call) Run(run func(ctx context.Context, key string, r io.Reader, size int64)) *BlobStore_Put_Call {
543+ _c.Call.Run(func(args mock.Arguments) {
544+ var arg0 context.Context
545+ if args[0] != nil {
546+ arg0 = args[0].(context.Context)
547+ }
548+ var arg1 string
549+ if args[1] != nil {
550+ arg1 = args[1].(string)
551+ }
552+ var arg2 io.Reader
553+ if args[2] != nil {
554+ arg2 = args[2].(io.Reader)
555+ }
556+ var arg3 int64
557+ if args[3] != nil {
558+ arg3 = args[3].(int64)
559+ }
560+ run(
561+ arg0,
562+ arg1,
563+ arg2,
564+ arg3,
565+ )
566+ })
567+ return _c
568+}
569+
570+func (_c *BlobStore_Put_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Put_Call {
571+ _c.Call.Return(blobInfo, err)
572+ return _c
573+}
574+
575+func (_c *BlobStore_Put_Call) RunAndReturn(run func(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error)) *BlobStore_Put_Call {
576+ _c.Call.Return(run)
577+ return _c
578+}
579+
580+// Stat provides a mock function for the type BlobStore
581+func (_mock *BlobStore) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
582+ ret := _mock.Called(ctx, key)
583+
584+ if len(ret) == 0 {
585+ panic("no return value specified for Stat")
586+ }
587+
588+ var r0 ports.BlobInfo
589+ var r1 error
590+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BlobInfo, error)); ok {
591+ return returnFunc(ctx, key)
592+ }
593+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BlobInfo); ok {
594+ r0 = returnFunc(ctx, key)
595+ } else {
596+ r0 = ret.Get(0).(ports.BlobInfo)
597+ }
598+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
599+ r1 = returnFunc(ctx, key)
600+ } else {
601+ r1 = ret.Error(1)
602+ }
603+ return r0, r1
604+}
605+
606+// BlobStore_Stat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stat'
607+type BlobStore_Stat_Call struct {
608+ *mock.Call
609+}
610+
611+// Stat is a helper method to define mock.On call
612+// - ctx context.Context
613+// - key string
614+func (_e *BlobStore_Expecter) Stat(ctx any, key any) *BlobStore_Stat_Call {
615+ return &BlobStore_Stat_Call{Call: _e.mock.On("Stat", ctx, key)}
616+}
617+
618+func (_c *BlobStore_Stat_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Stat_Call {
619+ _c.Call.Run(func(args mock.Arguments) {
620+ var arg0 context.Context
621+ if args[0] != nil {
622+ arg0 = args[0].(context.Context)
623+ }
624+ var arg1 string
625+ if args[1] != nil {
626+ arg1 = args[1].(string)
627+ }
628+ run(
629+ arg0,
630+ arg1,
631+ )
632+ })
633+ return _c
634+}
635+
636+func (_c *BlobStore_Stat_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Stat_Call {
637+ _c.Call.Return(blobInfo, err)
638+ return _c
639+}
640+
641+func (_c *BlobStore_Stat_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.BlobInfo, error)) *BlobStore_Stat_Call {
642+ _c.Call.Return(run)
643+ return _c
644+}
M internal/ports/mocks/CIArtifactFiles.go +0 -276
@@ -116,84 +116,6 @@ func (_c *CIArtifactFiles_ArtifactIDs_Call) RunAndReturn(run func(ctx context.Co
116116 return _c
117117 }
118118
119-// Assemble provides a mock function for the type CIArtifactFiles
120-func (_mock *CIArtifactFiles) Assemble(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error) {
121- ret := _mock.Called(ctx, ref, files)
122-
123- if len(ret) == 0 {
124- panic("no return value specified for Assemble")
125- }
126-
127- var r0 int64
128- var r1 string
129- var r2 error
130- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, []string) (int64, string, error)); ok {
131- return returnFunc(ctx, ref, files)
132- }
133- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, []string) int64); ok {
134- r0 = returnFunc(ctx, ref, files)
135- } else {
136- r0 = ret.Get(0).(int64)
137- }
138- if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CIArtifactRef, []string) string); ok {
139- r1 = returnFunc(ctx, ref, files)
140- } else {
141- r1 = ret.Get(1).(string)
142- }
143- if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CIArtifactRef, []string) error); ok {
144- r2 = returnFunc(ctx, ref, files)
145- } else {
146- r2 = ret.Error(2)
147- }
148- return r0, r1, r2
149-}
150-
151-// CIArtifactFiles_Assemble_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Assemble'
152-type CIArtifactFiles_Assemble_Call struct {
153- *mock.Call
154-}
155-
156-// Assemble is a helper method to define mock.On call
157-// - ctx context.Context
158-// - ref ports.CIArtifactRef
159-// - files []string
160-func (_e *CIArtifactFiles_Expecter) Assemble(ctx any, ref any, files any) *CIArtifactFiles_Assemble_Call {
161- return &CIArtifactFiles_Assemble_Call{Call: _e.mock.On("Assemble", ctx, ref, files)}
162-}
163-
164-func (_c *CIArtifactFiles_Assemble_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef, files []string)) *CIArtifactFiles_Assemble_Call {
165- _c.Call.Run(func(args mock.Arguments) {
166- var arg0 context.Context
167- if args[0] != nil {
168- arg0 = args[0].(context.Context)
169- }
170- var arg1 ports.CIArtifactRef
171- if args[1] != nil {
172- arg1 = args[1].(ports.CIArtifactRef)
173- }
174- var arg2 []string
175- if args[2] != nil {
176- arg2 = args[2].([]string)
177- }
178- run(
179- arg0,
180- arg1,
181- arg2,
182- )
183- })
184- return _c
185-}
186-
187-func (_c *CIArtifactFiles_Assemble_Call) Return(size int64, digest string, err error) *CIArtifactFiles_Assemble_Call {
188- _c.Call.Return(size, digest, err)
189- return _c
190-}
191-
192-func (_c *CIArtifactFiles_Assemble_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error)) *CIArtifactFiles_Assemble_Call {
193- _c.Call.Return(run)
194- return _c
195-}
196-
197119 // Open provides a mock function for the type CIArtifactFiles
198120 func (_mock *CIArtifactFiles) Open(ctx context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) {
199121 ret := _mock.Called(ctx, ref, offset)
@@ -268,84 +190,6 @@ func (_c *CIArtifactFiles_Open_Call) RunAndReturn(run func(ctx context.Context,
268190 return _c
269191 }
270192
271-// PutBlock provides a mock function for the type CIArtifactFiles
272-func (_mock *CIArtifactFiles) PutBlock(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error) {
273- ret := _mock.Called(ctx, ref, file, src)
274-
275- if len(ret) == 0 {
276- panic("no return value specified for PutBlock")
277- }
278-
279- var r0 int64
280- var r1 error
281- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, string, io.Reader) (int64, error)); ok {
282- return returnFunc(ctx, ref, file, src)
283- }
284- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, string, io.Reader) int64); ok {
285- r0 = returnFunc(ctx, ref, file, src)
286- } else {
287- r0 = ret.Get(0).(int64)
288- }
289- if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CIArtifactRef, string, io.Reader) error); ok {
290- r1 = returnFunc(ctx, ref, file, src)
291- } else {
292- r1 = ret.Error(1)
293- }
294- return r0, r1
295-}
296-
297-// CIArtifactFiles_PutBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutBlock'
298-type CIArtifactFiles_PutBlock_Call struct {
299- *mock.Call
300-}
301-
302-// PutBlock is a helper method to define mock.On call
303-// - ctx context.Context
304-// - ref ports.CIArtifactRef
305-// - file string
306-// - src io.Reader
307-func (_e *CIArtifactFiles_Expecter) PutBlock(ctx any, ref any, file any, src any) *CIArtifactFiles_PutBlock_Call {
308- return &CIArtifactFiles_PutBlock_Call{Call: _e.mock.On("PutBlock", ctx, ref, file, src)}
309-}
310-
311-func (_c *CIArtifactFiles_PutBlock_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader)) *CIArtifactFiles_PutBlock_Call {
312- _c.Call.Run(func(args mock.Arguments) {
313- var arg0 context.Context
314- if args[0] != nil {
315- arg0 = args[0].(context.Context)
316- }
317- var arg1 ports.CIArtifactRef
318- if args[1] != nil {
319- arg1 = args[1].(ports.CIArtifactRef)
320- }
321- var arg2 string
322- if args[2] != nil {
323- arg2 = args[2].(string)
324- }
325- var arg3 io.Reader
326- if args[3] != nil {
327- arg3 = args[3].(io.Reader)
328- }
329- run(
330- arg0,
331- arg1,
332- arg2,
333- arg3,
334- )
335- })
336- return _c
337-}
338-
339-func (_c *CIArtifactFiles_PutBlock_Call) Return(n int64, err error) *CIArtifactFiles_PutBlock_Call {
340- _c.Call.Return(n, err)
341- return _c
342-}
343-
344-func (_c *CIArtifactFiles_PutBlock_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error)) *CIArtifactFiles_PutBlock_Call {
345- _c.Call.Return(run)
346- return _c
347-}
348-
349193 // Remove provides a mock function for the type CIArtifactFiles
350194 func (_mock *CIArtifactFiles) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
351195 ret := _mock.Called(ctx, ref)
@@ -403,126 +247,6 @@ func (_c *CIArtifactFiles_Remove_Call) RunAndReturn(run func(ctx context.Context
403247 return _c
404248 }
405249
406-// RemoveBlock provides a mock function for the type CIArtifactFiles
407-func (_mock *CIArtifactFiles) RemoveBlock(ctx context.Context, ref ports.CIArtifactRef, file string) error {
408- ret := _mock.Called(ctx, ref, file)
409-
410- if len(ret) == 0 {
411- panic("no return value specified for RemoveBlock")
412- }
413-
414- var r0 error
415- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, string) error); ok {
416- r0 = returnFunc(ctx, ref, file)
417- } else {
418- r0 = ret.Error(0)
419- }
420- return r0
421-}
422-
423-// CIArtifactFiles_RemoveBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveBlock'
424-type CIArtifactFiles_RemoveBlock_Call struct {
425- *mock.Call
426-}
427-
428-// RemoveBlock is a helper method to define mock.On call
429-// - ctx context.Context
430-// - ref ports.CIArtifactRef
431-// - file string
432-func (_e *CIArtifactFiles_Expecter) RemoveBlock(ctx any, ref any, file any) *CIArtifactFiles_RemoveBlock_Call {
433- return &CIArtifactFiles_RemoveBlock_Call{Call: _e.mock.On("RemoveBlock", ctx, ref, file)}
434-}
435-
436-func (_c *CIArtifactFiles_RemoveBlock_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef, file string)) *CIArtifactFiles_RemoveBlock_Call {
437- _c.Call.Run(func(args mock.Arguments) {
438- var arg0 context.Context
439- if args[0] != nil {
440- arg0 = args[0].(context.Context)
441- }
442- var arg1 ports.CIArtifactRef
443- if args[1] != nil {
444- arg1 = args[1].(ports.CIArtifactRef)
445- }
446- var arg2 string
447- if args[2] != nil {
448- arg2 = args[2].(string)
449- }
450- run(
451- arg0,
452- arg1,
453- arg2,
454- )
455- })
456- return _c
457-}
458-
459-func (_c *CIArtifactFiles_RemoveBlock_Call) Return(err error) *CIArtifactFiles_RemoveBlock_Call {
460- _c.Call.Return(err)
461- return _c
462-}
463-
464-func (_c *CIArtifactFiles_RemoveBlock_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef, file string) error) *CIArtifactFiles_RemoveBlock_Call {
465- _c.Call.Return(run)
466- return _c
467-}
468-
469-// RemoveBlocks provides a mock function for the type CIArtifactFiles
470-func (_mock *CIArtifactFiles) RemoveBlocks(ctx context.Context, ref ports.CIArtifactRef) error {
471- ret := _mock.Called(ctx, ref)
472-
473- if len(ret) == 0 {
474- panic("no return value specified for RemoveBlocks")
475- }
476-
477- var r0 error
478- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef) error); ok {
479- r0 = returnFunc(ctx, ref)
480- } else {
481- r0 = ret.Error(0)
482- }
483- return r0
484-}
485-
486-// CIArtifactFiles_RemoveBlocks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveBlocks'
487-type CIArtifactFiles_RemoveBlocks_Call struct {
488- *mock.Call
489-}
490-
491-// RemoveBlocks is a helper method to define mock.On call
492-// - ctx context.Context
493-// - ref ports.CIArtifactRef
494-func (_e *CIArtifactFiles_Expecter) RemoveBlocks(ctx any, ref any) *CIArtifactFiles_RemoveBlocks_Call {
495- return &CIArtifactFiles_RemoveBlocks_Call{Call: _e.mock.On("RemoveBlocks", ctx, ref)}
496-}
497-
498-func (_c *CIArtifactFiles_RemoveBlocks_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef)) *CIArtifactFiles_RemoveBlocks_Call {
499- _c.Call.Run(func(args mock.Arguments) {
500- var arg0 context.Context
501- if args[0] != nil {
502- arg0 = args[0].(context.Context)
503- }
504- var arg1 ports.CIArtifactRef
505- if args[1] != nil {
506- arg1 = args[1].(ports.CIArtifactRef)
507- }
508- run(
509- arg0,
510- arg1,
511- )
512- })
513- return _c
514-}
515-
516-func (_c *CIArtifactFiles_RemoveBlocks_Call) Return(err error) *CIArtifactFiles_RemoveBlocks_Call {
517- _c.Call.Return(err)
518- return _c
519-}
520-
521-func (_c *CIArtifactFiles_RemoveBlocks_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef) error) *CIArtifactFiles_RemoveBlocks_Call {
522- _c.Call.Return(run)
523- return _c
524-}
525-
526250 // RemoveRuns provides a mock function for the type CIArtifactFiles
527251 func (_mock *CIArtifactFiles) RemoveRuns(ctx context.Context, runIDs []int64) error {
528252 ret := _mock.Called(ctx, runIDs)
A internal/ports/mocks/GitHubReleaseAPI.go +1044 -0
@@ -0,0 +1,1044 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewGitHubReleaseAPI creates a new instance of GitHubReleaseAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewGitHubReleaseAPI(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *GitHubReleaseAPI {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &GitHubReleaseAPI{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// GitHubReleaseAPI is an autogenerated mock type for the GitHubReleaseAPI type
40+type GitHubReleaseAPI struct {
41+ mock.Mock
42+}
43+
44+type GitHubReleaseAPI_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *GitHubReleaseAPI) EXPECT() *GitHubReleaseAPI_Expecter {
49+ return &GitHubReleaseAPI_Expecter{mock: &_m.Mock}
50+}
51+
52+// AssetByID provides a mock function for the type GitHubReleaseAPI
53+func (_mock *GitHubReleaseAPI) AssetByID(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error) {
54+ ret := _mock.Called(ctx, p, owner, repo, assetID)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for AssetByID")
58+ }
59+
60+ var r0 domain.ReleaseAsset
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) (domain.ReleaseAsset, error)); ok {
63+ return returnFunc(ctx, p, owner, repo, assetID)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) domain.ReleaseAsset); ok {
66+ r0 = returnFunc(ctx, p, owner, repo, assetID)
67+ } else {
68+ r0 = ret.Get(0).(domain.ReleaseAsset)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok {
71+ r1 = returnFunc(ctx, p, owner, repo, assetID)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// GitHubReleaseAPI_AssetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetByID'
79+type GitHubReleaseAPI_AssetByID_Call struct {
80+ *mock.Call
81+}
82+
83+// AssetByID is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+// - owner string
87+// - repo string
88+// - assetID int64
89+func (_e *GitHubReleaseAPI_Expecter) AssetByID(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_AssetByID_Call {
90+ return &GitHubReleaseAPI_AssetByID_Call{Call: _e.mock.On("AssetByID", ctx, p, owner, repo, assetID)}
91+}
92+
93+func (_c *GitHubReleaseAPI_AssetByID_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_AssetByID_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 domain.Principal
100+ if args[1] != nil {
101+ arg1 = args[1].(domain.Principal)
102+ }
103+ var arg2 string
104+ if args[2] != nil {
105+ arg2 = args[2].(string)
106+ }
107+ var arg3 string
108+ if args[3] != nil {
109+ arg3 = args[3].(string)
110+ }
111+ var arg4 int64
112+ if args[4] != nil {
113+ arg4 = args[4].(int64)
114+ }
115+ run(
116+ arg0,
117+ arg1,
118+ arg2,
119+ arg3,
120+ arg4,
121+ )
122+ })
123+ return _c
124+}
125+
126+func (_c *GitHubReleaseAPI_AssetByID_Call) Return(releaseAsset domain.ReleaseAsset, err error) *GitHubReleaseAPI_AssetByID_Call {
127+ _c.Call.Return(releaseAsset, err)
128+ return _c
129+}
130+
131+func (_c *GitHubReleaseAPI_AssetByID_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error)) *GitHubReleaseAPI_AssetByID_Call {
132+ _c.Call.Return(run)
133+ return _c
134+}
135+
136+// CreateRelease provides a mock function for the type GitHubReleaseAPI
137+func (_mock *GitHubReleaseAPI) CreateRelease(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error) {
138+ ret := _mock.Called(ctx, p, owner, repo, body)
139+
140+ if len(ret) == 0 {
141+ panic("no return value specified for CreateRelease")
142+ }
143+
144+ var r0 []byte
145+ var r1 error
146+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) ([]byte, error)); ok {
147+ return returnFunc(ctx, p, owner, repo, body)
148+ }
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) []byte); ok {
150+ r0 = returnFunc(ctx, p, owner, repo, body)
151+ } else {
152+ if ret.Get(0) != nil {
153+ r0 = ret.Get(0).([]byte)
154+ }
155+ }
156+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, []byte) error); ok {
157+ r1 = returnFunc(ctx, p, owner, repo, body)
158+ } else {
159+ r1 = ret.Error(1)
160+ }
161+ return r0, r1
162+}
163+
164+// GitHubReleaseAPI_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease'
165+type GitHubReleaseAPI_CreateRelease_Call struct {
166+ *mock.Call
167+}
168+
169+// CreateRelease is a helper method to define mock.On call
170+// - ctx context.Context
171+// - p domain.Principal
172+// - owner string
173+// - repo string
174+// - body []byte
175+func (_e *GitHubReleaseAPI_Expecter) CreateRelease(ctx any, p any, owner any, repo any, body any) *GitHubReleaseAPI_CreateRelease_Call {
176+ return &GitHubReleaseAPI_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, owner, repo, body)}
177+}
178+
179+func (_c *GitHubReleaseAPI_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte)) *GitHubReleaseAPI_CreateRelease_Call {
180+ _c.Call.Run(func(args mock.Arguments) {
181+ var arg0 context.Context
182+ if args[0] != nil {
183+ arg0 = args[0].(context.Context)
184+ }
185+ var arg1 domain.Principal
186+ if args[1] != nil {
187+ arg1 = args[1].(domain.Principal)
188+ }
189+ var arg2 string
190+ if args[2] != nil {
191+ arg2 = args[2].(string)
192+ }
193+ var arg3 string
194+ if args[3] != nil {
195+ arg3 = args[3].(string)
196+ }
197+ var arg4 []byte
198+ if args[4] != nil {
199+ arg4 = args[4].([]byte)
200+ }
201+ run(
202+ arg0,
203+ arg1,
204+ arg2,
205+ arg3,
206+ arg4,
207+ )
208+ })
209+ return _c
210+}
211+
212+func (_c *GitHubReleaseAPI_CreateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_CreateRelease_Call {
213+ _c.Call.Return(bytes, err)
214+ return _c
215+}
216+
217+func (_c *GitHubReleaseAPI_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error)) *GitHubReleaseAPI_CreateRelease_Call {
218+ _c.Call.Return(run)
219+ return _c
220+}
221+
222+// DeleteAsset provides a mock function for the type GitHubReleaseAPI
223+func (_mock *GitHubReleaseAPI) DeleteAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error {
224+ ret := _mock.Called(ctx, p, owner, repo, assetID)
225+
226+ if len(ret) == 0 {
227+ panic("no return value specified for DeleteAsset")
228+ }
229+
230+ var r0 error
231+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok {
232+ r0 = returnFunc(ctx, p, owner, repo, assetID)
233+ } else {
234+ r0 = ret.Error(0)
235+ }
236+ return r0
237+}
238+
239+// GitHubReleaseAPI_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset'
240+type GitHubReleaseAPI_DeleteAsset_Call struct {
241+ *mock.Call
242+}
243+
244+// DeleteAsset is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - owner string
248+// - repo string
249+// - assetID int64
250+func (_e *GitHubReleaseAPI_Expecter) DeleteAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_DeleteAsset_Call {
251+ return &GitHubReleaseAPI_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, owner, repo, assetID)}
252+}
253+
254+func (_c *GitHubReleaseAPI_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_DeleteAsset_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 string
265+ if args[2] != nil {
266+ arg2 = args[2].(string)
267+ }
268+ var arg3 string
269+ if args[3] != nil {
270+ arg3 = args[3].(string)
271+ }
272+ var arg4 int64
273+ if args[4] != nil {
274+ arg4 = args[4].(int64)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *GitHubReleaseAPI_DeleteAsset_Call) Return(err error) *GitHubReleaseAPI_DeleteAsset_Call {
288+ _c.Call.Return(err)
289+ return _c
290+}
291+
292+func (_c *GitHubReleaseAPI_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error) *GitHubReleaseAPI_DeleteAsset_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
297+// DeleteRelease provides a mock function for the type GitHubReleaseAPI
298+func (_mock *GitHubReleaseAPI) DeleteRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error {
299+ ret := _mock.Called(ctx, p, owner, repo, id)
300+
301+ if len(ret) == 0 {
302+ panic("no return value specified for DeleteRelease")
303+ }
304+
305+ var r0 error
306+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok {
307+ r0 = returnFunc(ctx, p, owner, repo, id)
308+ } else {
309+ r0 = ret.Error(0)
310+ }
311+ return r0
312+}
313+
314+// GitHubReleaseAPI_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease'
315+type GitHubReleaseAPI_DeleteRelease_Call struct {
316+ *mock.Call
317+}
318+
319+// DeleteRelease is a helper method to define mock.On call
320+// - ctx context.Context
321+// - p domain.Principal
322+// - owner string
323+// - repo string
324+// - id int64
325+func (_e *GitHubReleaseAPI_Expecter) DeleteRelease(ctx any, p any, owner any, repo any, id any) *GitHubReleaseAPI_DeleteRelease_Call {
326+ return &GitHubReleaseAPI_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, owner, repo, id)}
327+}
328+
329+func (_c *GitHubReleaseAPI_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64)) *GitHubReleaseAPI_DeleteRelease_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ var arg0 context.Context
332+ if args[0] != nil {
333+ arg0 = args[0].(context.Context)
334+ }
335+ var arg1 domain.Principal
336+ if args[1] != nil {
337+ arg1 = args[1].(domain.Principal)
338+ }
339+ var arg2 string
340+ if args[2] != nil {
341+ arg2 = args[2].(string)
342+ }
343+ var arg3 string
344+ if args[3] != nil {
345+ arg3 = args[3].(string)
346+ }
347+ var arg4 int64
348+ if args[4] != nil {
349+ arg4 = args[4].(int64)
350+ }
351+ run(
352+ arg0,
353+ arg1,
354+ arg2,
355+ arg3,
356+ arg4,
357+ )
358+ })
359+ return _c
360+}
361+
362+func (_c *GitHubReleaseAPI_DeleteRelease_Call) Return(err error) *GitHubReleaseAPI_DeleteRelease_Call {
363+ _c.Call.Return(err)
364+ return _c
365+}
366+
367+func (_c *GitHubReleaseAPI_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error) *GitHubReleaseAPI_DeleteRelease_Call {
368+ _c.Call.Return(run)
369+ return _c
370+}
371+
372+// GetAsset provides a mock function for the type GitHubReleaseAPI
373+func (_mock *GitHubReleaseAPI) GetAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error) {
374+ ret := _mock.Called(ctx, p, owner, repo, assetID)
375+
376+ if len(ret) == 0 {
377+ panic("no return value specified for GetAsset")
378+ }
379+
380+ var r0 []byte
381+ var r1 error
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) ([]byte, error)); ok {
383+ return returnFunc(ctx, p, owner, repo, assetID)
384+ }
385+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) []byte); ok {
386+ r0 = returnFunc(ctx, p, owner, repo, assetID)
387+ } else {
388+ if ret.Get(0) != nil {
389+ r0 = ret.Get(0).([]byte)
390+ }
391+ }
392+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok {
393+ r1 = returnFunc(ctx, p, owner, repo, assetID)
394+ } else {
395+ r1 = ret.Error(1)
396+ }
397+ return r0, r1
398+}
399+
400+// GitHubReleaseAPI_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset'
401+type GitHubReleaseAPI_GetAsset_Call struct {
402+ *mock.Call
403+}
404+
405+// GetAsset is a helper method to define mock.On call
406+// - ctx context.Context
407+// - p domain.Principal
408+// - owner string
409+// - repo string
410+// - assetID int64
411+func (_e *GitHubReleaseAPI_Expecter) GetAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_GetAsset_Call {
412+ return &GitHubReleaseAPI_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, owner, repo, assetID)}
413+}
414+
415+func (_c *GitHubReleaseAPI_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_GetAsset_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 context.Context
418+ if args[0] != nil {
419+ arg0 = args[0].(context.Context)
420+ }
421+ var arg1 domain.Principal
422+ if args[1] != nil {
423+ arg1 = args[1].(domain.Principal)
424+ }
425+ var arg2 string
426+ if args[2] != nil {
427+ arg2 = args[2].(string)
428+ }
429+ var arg3 string
430+ if args[3] != nil {
431+ arg3 = args[3].(string)
432+ }
433+ var arg4 int64
434+ if args[4] != nil {
435+ arg4 = args[4].(int64)
436+ }
437+ run(
438+ arg0,
439+ arg1,
440+ arg2,
441+ arg3,
442+ arg4,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *GitHubReleaseAPI_GetAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetAsset_Call {
449+ _c.Call.Return(bytes, err)
450+ return _c
451+}
452+
453+func (_c *GitHubReleaseAPI_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error)) *GitHubReleaseAPI_GetAsset_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
457+
458+// GetRelease provides a mock function for the type GitHubReleaseAPI
459+func (_mock *GitHubReleaseAPI) GetRelease(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error) {
460+ ret := _mock.Called(ctx, p, owner, repo, ref)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for GetRelease")
464+ }
465+
466+ var r0 []byte
467+ var r1 error
468+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) ([]byte, error)); ok {
469+ return returnFunc(ctx, p, owner, repo, ref)
470+ }
471+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) []byte); ok {
472+ r0 = returnFunc(ctx, p, owner, repo, ref)
473+ } else {
474+ if ret.Get(0) != nil {
475+ r0 = ret.Get(0).([]byte)
476+ }
477+ }
478+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) error); ok {
479+ r1 = returnFunc(ctx, p, owner, repo, ref)
480+ } else {
481+ r1 = ret.Error(1)
482+ }
483+ return r0, r1
484+}
485+
486+// GitHubReleaseAPI_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease'
487+type GitHubReleaseAPI_GetRelease_Call struct {
488+ *mock.Call
489+}
490+
491+// GetRelease is a helper method to define mock.On call
492+// - ctx context.Context
493+// - p domain.Principal
494+// - owner string
495+// - repo string
496+// - ref ports.ReleaseRef
497+func (_e *GitHubReleaseAPI_Expecter) GetRelease(ctx any, p any, owner any, repo any, ref any) *GitHubReleaseAPI_GetRelease_Call {
498+ return &GitHubReleaseAPI_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, owner, repo, ref)}
499+}
500+
501+func (_c *GitHubReleaseAPI_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef)) *GitHubReleaseAPI_GetRelease_Call {
502+ _c.Call.Run(func(args mock.Arguments) {
503+ var arg0 context.Context
504+ if args[0] != nil {
505+ arg0 = args[0].(context.Context)
506+ }
507+ var arg1 domain.Principal
508+ if args[1] != nil {
509+ arg1 = args[1].(domain.Principal)
510+ }
511+ var arg2 string
512+ if args[2] != nil {
513+ arg2 = args[2].(string)
514+ }
515+ var arg3 string
516+ if args[3] != nil {
517+ arg3 = args[3].(string)
518+ }
519+ var arg4 ports.ReleaseRef
520+ if args[4] != nil {
521+ arg4 = args[4].(ports.ReleaseRef)
522+ }
523+ run(
524+ arg0,
525+ arg1,
526+ arg2,
527+ arg3,
528+ arg4,
529+ )
530+ })
531+ return _c
532+}
533+
534+func (_c *GitHubReleaseAPI_GetRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetRelease_Call {
535+ _c.Call.Return(bytes, err)
536+ return _c
537+}
538+
539+func (_c *GitHubReleaseAPI_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error)) *GitHubReleaseAPI_GetRelease_Call {
540+ _c.Call.Return(run)
541+ return _c
542+}
543+
544+// ListAssets provides a mock function for the type GitHubReleaseAPI
545+func (_mock *GitHubReleaseAPI) ListAssets(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error) {
546+ ret := _mock.Called(ctx, p, owner, repo, id, page, perPage)
547+
548+ if len(ret) == 0 {
549+ panic("no return value specified for ListAssets")
550+ }
551+
552+ var r0 []byte
553+ var r1 string
554+ var r2 error
555+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) ([]byte, string, error)); ok {
556+ return returnFunc(ctx, p, owner, repo, id, page, perPage)
557+ }
558+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) []byte); ok {
559+ r0 = returnFunc(ctx, p, owner, repo, id, page, perPage)
560+ } else {
561+ if ret.Get(0) != nil {
562+ r0 = ret.Get(0).([]byte)
563+ }
564+ }
565+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, int, int) string); ok {
566+ r1 = returnFunc(ctx, p, owner, repo, id, page, perPage)
567+ } else {
568+ r1 = ret.Get(1).(string)
569+ }
570+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int64, int, int) error); ok {
571+ r2 = returnFunc(ctx, p, owner, repo, id, page, perPage)
572+ } else {
573+ r2 = ret.Error(2)
574+ }
575+ return r0, r1, r2
576+}
577+
578+// GitHubReleaseAPI_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets'
579+type GitHubReleaseAPI_ListAssets_Call struct {
580+ *mock.Call
581+}
582+
583+// ListAssets is a helper method to define mock.On call
584+// - ctx context.Context
585+// - p domain.Principal
586+// - owner string
587+// - repo string
588+// - id int64
589+// - page int
590+// - perPage int
591+func (_e *GitHubReleaseAPI_Expecter) ListAssets(ctx any, p any, owner any, repo any, id any, page any, perPage any) *GitHubReleaseAPI_ListAssets_Call {
592+ return &GitHubReleaseAPI_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, owner, repo, id, page, perPage)}
593+}
594+
595+func (_c *GitHubReleaseAPI_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int)) *GitHubReleaseAPI_ListAssets_Call {
596+ _c.Call.Run(func(args mock.Arguments) {
597+ var arg0 context.Context
598+ if args[0] != nil {
599+ arg0 = args[0].(context.Context)
600+ }
601+ var arg1 domain.Principal
602+ if args[1] != nil {
603+ arg1 = args[1].(domain.Principal)
604+ }
605+ var arg2 string
606+ if args[2] != nil {
607+ arg2 = args[2].(string)
608+ }
609+ var arg3 string
610+ if args[3] != nil {
611+ arg3 = args[3].(string)
612+ }
613+ var arg4 int64
614+ if args[4] != nil {
615+ arg4 = args[4].(int64)
616+ }
617+ var arg5 int
618+ if args[5] != nil {
619+ arg5 = args[5].(int)
620+ }
621+ var arg6 int
622+ if args[6] != nil {
623+ arg6 = args[6].(int)
624+ }
625+ run(
626+ arg0,
627+ arg1,
628+ arg2,
629+ arg3,
630+ arg4,
631+ arg5,
632+ arg6,
633+ )
634+ })
635+ return _c
636+}
637+
638+func (_c *GitHubReleaseAPI_ListAssets_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListAssets_Call {
639+ _c.Call.Return(body, link, err)
640+ return _c
641+}
642+
643+func (_c *GitHubReleaseAPI_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListAssets_Call {
644+ _c.Call.Return(run)
645+ return _c
646+}
647+
648+// ListReleases provides a mock function for the type GitHubReleaseAPI
649+func (_mock *GitHubReleaseAPI) ListReleases(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error) {
650+ ret := _mock.Called(ctx, p, owner, repo, page, perPage)
651+
652+ if len(ret) == 0 {
653+ panic("no return value specified for ListReleases")
654+ }
655+
656+ var r0 []byte
657+ var r1 string
658+ var r2 error
659+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) ([]byte, string, error)); ok {
660+ return returnFunc(ctx, p, owner, repo, page, perPage)
661+ }
662+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) []byte); ok {
663+ r0 = returnFunc(ctx, p, owner, repo, page, perPage)
664+ } else {
665+ if ret.Get(0) != nil {
666+ r0 = ret.Get(0).([]byte)
667+ }
668+ }
669+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int, int) string); ok {
670+ r1 = returnFunc(ctx, p, owner, repo, page, perPage)
671+ } else {
672+ r1 = ret.Get(1).(string)
673+ }
674+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int, int) error); ok {
675+ r2 = returnFunc(ctx, p, owner, repo, page, perPage)
676+ } else {
677+ r2 = ret.Error(2)
678+ }
679+ return r0, r1, r2
680+}
681+
682+// GitHubReleaseAPI_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases'
683+type GitHubReleaseAPI_ListReleases_Call struct {
684+ *mock.Call
685+}
686+
687+// ListReleases is a helper method to define mock.On call
688+// - ctx context.Context
689+// - p domain.Principal
690+// - owner string
691+// - repo string
692+// - page int
693+// - perPage int
694+func (_e *GitHubReleaseAPI_Expecter) ListReleases(ctx any, p any, owner any, repo any, page any, perPage any) *GitHubReleaseAPI_ListReleases_Call {
695+ return &GitHubReleaseAPI_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, owner, repo, page, perPage)}
696+}
697+
698+func (_c *GitHubReleaseAPI_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int)) *GitHubReleaseAPI_ListReleases_Call {
699+ _c.Call.Run(func(args mock.Arguments) {
700+ var arg0 context.Context
701+ if args[0] != nil {
702+ arg0 = args[0].(context.Context)
703+ }
704+ var arg1 domain.Principal
705+ if args[1] != nil {
706+ arg1 = args[1].(domain.Principal)
707+ }
708+ var arg2 string
709+ if args[2] != nil {
710+ arg2 = args[2].(string)
711+ }
712+ var arg3 string
713+ if args[3] != nil {
714+ arg3 = args[3].(string)
715+ }
716+ var arg4 int
717+ if args[4] != nil {
718+ arg4 = args[4].(int)
719+ }
720+ var arg5 int
721+ if args[5] != nil {
722+ arg5 = args[5].(int)
723+ }
724+ run(
725+ arg0,
726+ arg1,
727+ arg2,
728+ arg3,
729+ arg4,
730+ arg5,
731+ )
732+ })
733+ return _c
734+}
735+
736+func (_c *GitHubReleaseAPI_ListReleases_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListReleases_Call {
737+ _c.Call.Return(body, link, err)
738+ return _c
739+}
740+
741+func (_c *GitHubReleaseAPI_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListReleases_Call {
742+ _c.Call.Return(run)
743+ return _c
744+}
745+
746+// UpdateAsset provides a mock function for the type GitHubReleaseAPI
747+func (_mock *GitHubReleaseAPI) UpdateAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error) {
748+ ret := _mock.Called(ctx, p, owner, repo, assetID, body)
749+
750+ if len(ret) == 0 {
751+ panic("no return value specified for UpdateAsset")
752+ }
753+
754+ var r0 []byte
755+ var r1 error
756+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok {
757+ return returnFunc(ctx, p, owner, repo, assetID, body)
758+ }
759+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok {
760+ r0 = returnFunc(ctx, p, owner, repo, assetID, body)
761+ } else {
762+ if ret.Get(0) != nil {
763+ r0 = ret.Get(0).([]byte)
764+ }
765+ }
766+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok {
767+ r1 = returnFunc(ctx, p, owner, repo, assetID, body)
768+ } else {
769+ r1 = ret.Error(1)
770+ }
771+ return r0, r1
772+}
773+
774+// GitHubReleaseAPI_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset'
775+type GitHubReleaseAPI_UpdateAsset_Call struct {
776+ *mock.Call
777+}
778+
779+// UpdateAsset is a helper method to define mock.On call
780+// - ctx context.Context
781+// - p domain.Principal
782+// - owner string
783+// - repo string
784+// - assetID int64
785+// - body []byte
786+func (_e *GitHubReleaseAPI_Expecter) UpdateAsset(ctx any, p any, owner any, repo any, assetID any, body any) *GitHubReleaseAPI_UpdateAsset_Call {
787+ return &GitHubReleaseAPI_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, owner, repo, assetID, body)}
788+}
789+
790+func (_c *GitHubReleaseAPI_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte)) *GitHubReleaseAPI_UpdateAsset_Call {
791+ _c.Call.Run(func(args mock.Arguments) {
792+ var arg0 context.Context
793+ if args[0] != nil {
794+ arg0 = args[0].(context.Context)
795+ }
796+ var arg1 domain.Principal
797+ if args[1] != nil {
798+ arg1 = args[1].(domain.Principal)
799+ }
800+ var arg2 string
801+ if args[2] != nil {
802+ arg2 = args[2].(string)
803+ }
804+ var arg3 string
805+ if args[3] != nil {
806+ arg3 = args[3].(string)
807+ }
808+ var arg4 int64
809+ if args[4] != nil {
810+ arg4 = args[4].(int64)
811+ }
812+ var arg5 []byte
813+ if args[5] != nil {
814+ arg5 = args[5].([]byte)
815+ }
816+ run(
817+ arg0,
818+ arg1,
819+ arg2,
820+ arg3,
821+ arg4,
822+ arg5,
823+ )
824+ })
825+ return _c
826+}
827+
828+func (_c *GitHubReleaseAPI_UpdateAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateAsset_Call {
829+ _c.Call.Return(bytes, err)
830+ return _c
831+}
832+
833+func (_c *GitHubReleaseAPI_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateAsset_Call {
834+ _c.Call.Return(run)
835+ return _c
836+}
837+
838+// UpdateRelease provides a mock function for the type GitHubReleaseAPI
839+func (_mock *GitHubReleaseAPI) UpdateRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error) {
840+ ret := _mock.Called(ctx, p, owner, repo, id, body)
841+
842+ if len(ret) == 0 {
843+ panic("no return value specified for UpdateRelease")
844+ }
845+
846+ var r0 []byte
847+ var r1 error
848+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok {
849+ return returnFunc(ctx, p, owner, repo, id, body)
850+ }
851+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok {
852+ r0 = returnFunc(ctx, p, owner, repo, id, body)
853+ } else {
854+ if ret.Get(0) != nil {
855+ r0 = ret.Get(0).([]byte)
856+ }
857+ }
858+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok {
859+ r1 = returnFunc(ctx, p, owner, repo, id, body)
860+ } else {
861+ r1 = ret.Error(1)
862+ }
863+ return r0, r1
864+}
865+
866+// GitHubReleaseAPI_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease'
867+type GitHubReleaseAPI_UpdateRelease_Call struct {
868+ *mock.Call
869+}
870+
871+// UpdateRelease is a helper method to define mock.On call
872+// - ctx context.Context
873+// - p domain.Principal
874+// - owner string
875+// - repo string
876+// - id int64
877+// - body []byte
878+func (_e *GitHubReleaseAPI_Expecter) UpdateRelease(ctx any, p any, owner any, repo any, id any, body any) *GitHubReleaseAPI_UpdateRelease_Call {
879+ return &GitHubReleaseAPI_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, owner, repo, id, body)}
880+}
881+
882+func (_c *GitHubReleaseAPI_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte)) *GitHubReleaseAPI_UpdateRelease_Call {
883+ _c.Call.Run(func(args mock.Arguments) {
884+ var arg0 context.Context
885+ if args[0] != nil {
886+ arg0 = args[0].(context.Context)
887+ }
888+ var arg1 domain.Principal
889+ if args[1] != nil {
890+ arg1 = args[1].(domain.Principal)
891+ }
892+ var arg2 string
893+ if args[2] != nil {
894+ arg2 = args[2].(string)
895+ }
896+ var arg3 string
897+ if args[3] != nil {
898+ arg3 = args[3].(string)
899+ }
900+ var arg4 int64
901+ if args[4] != nil {
902+ arg4 = args[4].(int64)
903+ }
904+ var arg5 []byte
905+ if args[5] != nil {
906+ arg5 = args[5].([]byte)
907+ }
908+ run(
909+ arg0,
910+ arg1,
911+ arg2,
912+ arg3,
913+ arg4,
914+ arg5,
915+ )
916+ })
917+ return _c
918+}
919+
920+func (_c *GitHubReleaseAPI_UpdateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateRelease_Call {
921+ _c.Call.Return(bytes, err)
922+ return _c
923+}
924+
925+func (_c *GitHubReleaseAPI_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateRelease_Call {
926+ _c.Call.Return(run)
927+ return _c
928+}
929+
930+// UploadAsset provides a mock function for the type GitHubReleaseAPI
931+func (_mock *GitHubReleaseAPI) UploadAsset(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error) {
932+ ret := _mock.Called(ctx, p, owner, repo, id, name, label, contentType, size, r)
933+
934+ if len(ret) == 0 {
935+ panic("no return value specified for UploadAsset")
936+ }
937+
938+ var r0 []byte
939+ var r1 error
940+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) ([]byte, error)); ok {
941+ return returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
942+ }
943+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) []byte); ok {
944+ r0 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
945+ } else {
946+ if ret.Get(0) != nil {
947+ r0 = ret.Get(0).([]byte)
948+ }
949+ }
950+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) error); ok {
951+ r1 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
952+ } else {
953+ r1 = ret.Error(1)
954+ }
955+ return r0, r1
956+}
957+
958+// GitHubReleaseAPI_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset'
959+type GitHubReleaseAPI_UploadAsset_Call struct {
960+ *mock.Call
961+}
962+
963+// UploadAsset is a helper method to define mock.On call
964+// - ctx context.Context
965+// - p domain.Principal
966+// - owner string
967+// - repo string
968+// - id int64
969+// - name string
970+// - label string
971+// - contentType string
972+// - size int64
973+// - r io.Reader
974+func (_e *GitHubReleaseAPI_Expecter) UploadAsset(ctx any, p any, owner any, repo any, id any, name any, label any, contentType any, size any, r any) *GitHubReleaseAPI_UploadAsset_Call {
975+ return &GitHubReleaseAPI_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, owner, repo, id, name, label, contentType, size, r)}
976+}
977+
978+func (_c *GitHubReleaseAPI_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader)) *GitHubReleaseAPI_UploadAsset_Call {
979+ _c.Call.Run(func(args mock.Arguments) {
980+ var arg0 context.Context
981+ if args[0] != nil {
982+ arg0 = args[0].(context.Context)
983+ }
984+ var arg1 domain.Principal
985+ if args[1] != nil {
986+ arg1 = args[1].(domain.Principal)
987+ }
988+ var arg2 string
989+ if args[2] != nil {
990+ arg2 = args[2].(string)
991+ }
992+ var arg3 string
993+ if args[3] != nil {
994+ arg3 = args[3].(string)
995+ }
996+ var arg4 int64
997+ if args[4] != nil {
998+ arg4 = args[4].(int64)
999+ }
1000+ var arg5 string
1001+ if args[5] != nil {
1002+ arg5 = args[5].(string)
1003+ }
1004+ var arg6 string
1005+ if args[6] != nil {
1006+ arg6 = args[6].(string)
1007+ }
1008+ var arg7 string
1009+ if args[7] != nil {
1010+ arg7 = args[7].(string)
1011+ }
1012+ var arg8 int64
1013+ if args[8] != nil {
1014+ arg8 = args[8].(int64)
1015+ }
1016+ var arg9 io.Reader
1017+ if args[9] != nil {
1018+ arg9 = args[9].(io.Reader)
1019+ }
1020+ run(
1021+ arg0,
1022+ arg1,
1023+ arg2,
1024+ arg3,
1025+ arg4,
1026+ arg5,
1027+ arg6,
1028+ arg7,
1029+ arg8,
1030+ arg9,
1031+ )
1032+ })
1033+ return _c
1034+}
1035+
1036+func (_c *GitHubReleaseAPI_UploadAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UploadAsset_Call {
1037+ _c.Call.Return(bytes, err)
1038+ return _c
1039+}
1040+
1041+func (_c *GitHubReleaseAPI_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error)) *GitHubReleaseAPI_UploadAsset_Call {
1042+ _c.Call.Return(run)
1043+ return _c
1044+}
A internal/ports/mocks/MultipartUpload.go +273 -0
@@ -0,0 +1,273 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMultipartUpload creates a new instance of MultipartUpload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMultipartUpload(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MultipartUpload {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &MultipartUpload{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// MultipartUpload is an autogenerated mock type for the MultipartUpload type
39+type MultipartUpload struct {
40+ mock.Mock
41+}
42+
43+type MultipartUpload_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *MultipartUpload) EXPECT() *MultipartUpload_Expecter {
48+ return &MultipartUpload_Expecter{mock: &_m.Mock}
49+}
50+
51+// Abort provides a mock function for the type MultipartUpload
52+func (_mock *MultipartUpload) Abort(ctx context.Context) error {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Abort")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
61+ r0 = returnFunc(ctx)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// MultipartUpload_Abort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Abort'
69+type MultipartUpload_Abort_Call struct {
70+ *mock.Call
71+}
72+
73+// Abort is a helper method to define mock.On call
74+// - ctx context.Context
75+func (_e *MultipartUpload_Expecter) Abort(ctx any) *MultipartUpload_Abort_Call {
76+ return &MultipartUpload_Abort_Call{Call: _e.mock.On("Abort", ctx)}
77+}
78+
79+func (_c *MultipartUpload_Abort_Call) Run(run func(ctx context.Context)) *MultipartUpload_Abort_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ run(
86+ arg0,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MultipartUpload_Abort_Call) Return(err error) *MultipartUpload_Abort_Call {
93+ _c.Call.Return(err)
94+ return _c
95+}
96+
97+func (_c *MultipartUpload_Abort_Call) RunAndReturn(run func(ctx context.Context) error) *MultipartUpload_Abort_Call {
98+ _c.Call.Return(run)
99+ return _c
100+}
101+
102+// Complete provides a mock function for the type MultipartUpload
103+func (_mock *MultipartUpload) Complete(ctx context.Context) (ports.BlobInfo, error) {
104+ ret := _mock.Called(ctx)
105+
106+ if len(ret) == 0 {
107+ panic("no return value specified for Complete")
108+ }
109+
110+ var r0 ports.BlobInfo
111+ var r1 error
112+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BlobInfo, error)); ok {
113+ return returnFunc(ctx)
114+ }
115+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BlobInfo); ok {
116+ r0 = returnFunc(ctx)
117+ } else {
118+ r0 = ret.Get(0).(ports.BlobInfo)
119+ }
120+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
121+ r1 = returnFunc(ctx)
122+ } else {
123+ r1 = ret.Error(1)
124+ }
125+ return r0, r1
126+}
127+
128+// MultipartUpload_Complete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Complete'
129+type MultipartUpload_Complete_Call struct {
130+ *mock.Call
131+}
132+
133+// Complete is a helper method to define mock.On call
134+// - ctx context.Context
135+func (_e *MultipartUpload_Expecter) Complete(ctx any) *MultipartUpload_Complete_Call {
136+ return &MultipartUpload_Complete_Call{Call: _e.mock.On("Complete", ctx)}
137+}
138+
139+func (_c *MultipartUpload_Complete_Call) Run(run func(ctx context.Context)) *MultipartUpload_Complete_Call {
140+ _c.Call.Run(func(args mock.Arguments) {
141+ var arg0 context.Context
142+ if args[0] != nil {
143+ arg0 = args[0].(context.Context)
144+ }
145+ run(
146+ arg0,
147+ )
148+ })
149+ return _c
150+}
151+
152+func (_c *MultipartUpload_Complete_Call) Return(blobInfo ports.BlobInfo, err error) *MultipartUpload_Complete_Call {
153+ _c.Call.Return(blobInfo, err)
154+ return _c
155+}
156+
157+func (_c *MultipartUpload_Complete_Call) RunAndReturn(run func(ctx context.Context) (ports.BlobInfo, error)) *MultipartUpload_Complete_Call {
158+ _c.Call.Return(run)
159+ return _c
160+}
161+
162+// ID provides a mock function for the type MultipartUpload
163+func (_mock *MultipartUpload) ID() string {
164+ ret := _mock.Called()
165+
166+ if len(ret) == 0 {
167+ panic("no return value specified for ID")
168+ }
169+
170+ var r0 string
171+ if returnFunc, ok := ret.Get(0).(func() string); ok {
172+ r0 = returnFunc()
173+ } else {
174+ r0 = ret.Get(0).(string)
175+ }
176+ return r0
177+}
178+
179+// MultipartUpload_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID'
180+type MultipartUpload_ID_Call struct {
181+ *mock.Call
182+}
183+
184+// ID is a helper method to define mock.On call
185+func (_e *MultipartUpload_Expecter) ID() *MultipartUpload_ID_Call {
186+ return &MultipartUpload_ID_Call{Call: _e.mock.On("ID")}
187+}
188+
189+func (_c *MultipartUpload_ID_Call) Run(run func()) *MultipartUpload_ID_Call {
190+ _c.Call.Run(func(args mock.Arguments) {
191+ run()
192+ })
193+ return _c
194+}
195+
196+func (_c *MultipartUpload_ID_Call) Return(s string) *MultipartUpload_ID_Call {
197+ _c.Call.Return(s)
198+ return _c
199+}
200+
201+func (_c *MultipartUpload_ID_Call) RunAndReturn(run func() string) *MultipartUpload_ID_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
205+
206+// UploadPart provides a mock function for the type MultipartUpload
207+func (_mock *MultipartUpload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
208+ ret := _mock.Called(ctx, n, r, size)
209+
210+ if len(ret) == 0 {
211+ panic("no return value specified for UploadPart")
212+ }
213+
214+ var r0 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, io.Reader, int64) error); ok {
216+ r0 = returnFunc(ctx, n, r, size)
217+ } else {
218+ r0 = ret.Error(0)
219+ }
220+ return r0
221+}
222+
223+// MultipartUpload_UploadPart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadPart'
224+type MultipartUpload_UploadPart_Call struct {
225+ *mock.Call
226+}
227+
228+// UploadPart is a helper method to define mock.On call
229+// - ctx context.Context
230+// - n int
231+// - r io.Reader
232+// - size int64
233+func (_e *MultipartUpload_Expecter) UploadPart(ctx any, n any, r any, size any) *MultipartUpload_UploadPart_Call {
234+ return &MultipartUpload_UploadPart_Call{Call: _e.mock.On("UploadPart", ctx, n, r, size)}
235+}
236+
237+func (_c *MultipartUpload_UploadPart_Call) Run(run func(ctx context.Context, n int, r io.Reader, size int64)) *MultipartUpload_UploadPart_Call {
238+ _c.Call.Run(func(args mock.Arguments) {
239+ var arg0 context.Context
240+ if args[0] != nil {
241+ arg0 = args[0].(context.Context)
242+ }
243+ var arg1 int
244+ if args[1] != nil {
245+ arg1 = args[1].(int)
246+ }
247+ var arg2 io.Reader
248+ if args[2] != nil {
249+ arg2 = args[2].(io.Reader)
250+ }
251+ var arg3 int64
252+ if args[3] != nil {
253+ arg3 = args[3].(int64)
254+ }
255+ run(
256+ arg0,
257+ arg1,
258+ arg2,
259+ arg3,
260+ )
261+ })
262+ return _c
263+}
264+
265+func (_c *MultipartUpload_UploadPart_Call) Return(err error) *MultipartUpload_UploadPart_Call {
266+ _c.Call.Return(err)
267+ return _c
268+}
269+
270+func (_c *MultipartUpload_UploadPart_Call) RunAndReturn(run func(ctx context.Context, n int, r io.Reader, size int64) error) *MultipartUpload_UploadPart_Call {
271+ _c.Call.Return(run)
272+ return _c
273+}
A internal/ports/mocks/ReleaseRepoCleaner.go +168 -0
@@ -0,0 +1,168 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewReleaseRepoCleaner creates a new instance of ReleaseRepoCleaner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewReleaseRepoCleaner(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *ReleaseRepoCleaner {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &ReleaseRepoCleaner{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// ReleaseRepoCleaner is an autogenerated mock type for the ReleaseRepoCleaner type
38+type ReleaseRepoCleaner struct {
39+ mock.Mock
40+}
41+
42+type ReleaseRepoCleaner_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *ReleaseRepoCleaner) EXPECT() *ReleaseRepoCleaner_Expecter {
47+ return &ReleaseRepoCleaner_Expecter{mock: &_m.Mock}
48+}
49+
50+// AssetsOf provides a mock function for the type ReleaseRepoCleaner
51+func (_mock *ReleaseRepoCleaner) AssetsOf(ctx context.Context, repo domain.RepoName) ([]domain.ReleaseAsset, error) {
52+ ret := _mock.Called(ctx, repo)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for AssetsOf")
56+ }
57+
58+ var r0 []domain.ReleaseAsset
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.ReleaseAsset, error)); ok {
61+ return returnFunc(ctx, repo)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.ReleaseAsset); ok {
64+ r0 = returnFunc(ctx, repo)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.ReleaseAsset)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
71+ r1 = returnFunc(ctx, repo)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// ReleaseRepoCleaner_AssetsOf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetsOf'
79+type ReleaseRepoCleaner_AssetsOf_Call struct {
80+ *mock.Call
81+}
82+
83+// AssetsOf is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+func (_e *ReleaseRepoCleaner_Expecter) AssetsOf(ctx any, repo any) *ReleaseRepoCleaner_AssetsOf_Call {
87+ return &ReleaseRepoCleaner_AssetsOf_Call{Call: _e.mock.On("AssetsOf", ctx, repo)}
88+}
89+
90+func (_c *ReleaseRepoCleaner_AssetsOf_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseRepoCleaner_AssetsOf_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.RepoName
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.RepoName)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ )
104+ })
105+ return _c
106+}
107+
108+func (_c *ReleaseRepoCleaner_AssetsOf_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseRepoCleaner_AssetsOf_Call {
109+ _c.Call.Return(releaseAssets, err)
110+ return _c
111+}
112+
113+func (_c *ReleaseRepoCleaner_AssetsOf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.ReleaseAsset, error)) *ReleaseRepoCleaner_AssetsOf_Call {
114+ _c.Call.Return(run)
115+ return _c
116+}
117+
118+// OnRepoDeleted provides a mock function for the type ReleaseRepoCleaner
119+func (_mock *ReleaseRepoCleaner) OnRepoDeleted(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset) {
120+ _mock.Called(ctx, repo, assets)
121+ return
122+}
123+
124+// ReleaseRepoCleaner_OnRepoDeleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnRepoDeleted'
125+type ReleaseRepoCleaner_OnRepoDeleted_Call struct {
126+ *mock.Call
127+}
128+
129+// OnRepoDeleted is a helper method to define mock.On call
130+// - ctx context.Context
131+// - repo domain.RepoName
132+// - assets []domain.ReleaseAsset
133+func (_e *ReleaseRepoCleaner_Expecter) OnRepoDeleted(ctx any, repo any, assets any) *ReleaseRepoCleaner_OnRepoDeleted_Call {
134+ return &ReleaseRepoCleaner_OnRepoDeleted_Call{Call: _e.mock.On("OnRepoDeleted", ctx, repo, assets)}
135+}
136+
137+func (_c *ReleaseRepoCleaner_OnRepoDeleted_Call) Run(run func(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset)) *ReleaseRepoCleaner_OnRepoDeleted_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 context.Context
140+ if args[0] != nil {
141+ arg0 = args[0].(context.Context)
142+ }
143+ var arg1 domain.RepoName
144+ if args[1] != nil {
145+ arg1 = args[1].(domain.RepoName)
146+ }
147+ var arg2 []domain.ReleaseAsset
148+ if args[2] != nil {
149+ arg2 = args[2].([]domain.ReleaseAsset)
150+ }
151+ run(
152+ arg0,
153+ arg1,
154+ arg2,
155+ )
156+ })
157+ return _c
158+}
159+
160+func (_c *ReleaseRepoCleaner_OnRepoDeleted_Call) Return() *ReleaseRepoCleaner_OnRepoDeleted_Call {
161+ _c.Call.Return()
162+ return _c
163+}
164+
165+func (_c *ReleaseRepoCleaner_OnRepoDeleted_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset)) *ReleaseRepoCleaner_OnRepoDeleted_Call {
166+ _c.Run(run)
167+ return _c
168+}
A internal/ports/mocks/ReleaseService.go +997 -0
@@ -0,0 +1,997 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewReleaseService creates a new instance of ReleaseService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewReleaseService(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *ReleaseService {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &ReleaseService{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// ReleaseService is an autogenerated mock type for the ReleaseService type
40+type ReleaseService struct {
41+ mock.Mock
42+}
43+
44+type ReleaseService_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *ReleaseService) EXPECT() *ReleaseService_Expecter {
49+ return &ReleaseService_Expecter{mock: &_m.Mock}
50+}
51+
52+// CreateRelease provides a mock function for the type ReleaseService
53+func (_mock *ReleaseService) CreateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error) {
54+ ret := _mock.Called(ctx, p, repo, params)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for CreateRelease")
58+ }
59+
60+ var r0 domain.Release
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateReleaseParams) (domain.Release, error)); ok {
63+ return returnFunc(ctx, p, repo, params)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateReleaseParams) domain.Release); ok {
66+ r0 = returnFunc(ctx, p, repo, params)
67+ } else {
68+ r0 = ret.Get(0).(domain.Release)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateReleaseParams) error); ok {
71+ r1 = returnFunc(ctx, p, repo, params)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// ReleaseService_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease'
79+type ReleaseService_CreateRelease_Call struct {
80+ *mock.Call
81+}
82+
83+// CreateRelease is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+// - repo domain.RepoName
87+// - params ports.CreateReleaseParams
88+func (_e *ReleaseService_Expecter) CreateRelease(ctx any, p any, repo any, params any) *ReleaseService_CreateRelease_Call {
89+ return &ReleaseService_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, repo, params)}
90+}
91+
92+func (_c *ReleaseService_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.CreateReleaseParams)) *ReleaseService_CreateRelease_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.Principal
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.Principal)
101+ }
102+ var arg2 domain.RepoName
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.RepoName)
105+ }
106+ var arg3 ports.CreateReleaseParams
107+ if args[3] != nil {
108+ arg3 = args[3].(ports.CreateReleaseParams)
109+ }
110+ run(
111+ arg0,
112+ arg1,
113+ arg2,
114+ arg3,
115+ )
116+ })
117+ return _c
118+}
119+
120+func (_c *ReleaseService_CreateRelease_Call) Return(release domain.Release, err error) *ReleaseService_CreateRelease_Call {
121+ _c.Call.Return(release, err)
122+ return _c
123+}
124+
125+func (_c *ReleaseService_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error)) *ReleaseService_CreateRelease_Call {
126+ _c.Call.Return(run)
127+ return _c
128+}
129+
130+// DeleteAsset provides a mock function for the type ReleaseService
131+func (_mock *ReleaseService) DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64) error {
132+ ret := _mock.Called(ctx, p, repo, assetID)
133+
134+ if len(ret) == 0 {
135+ panic("no return value specified for DeleteAsset")
136+ }
137+
138+ var r0 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
140+ r0 = returnFunc(ctx, p, repo, assetID)
141+ } else {
142+ r0 = ret.Error(0)
143+ }
144+ return r0
145+}
146+
147+// ReleaseService_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset'
148+type ReleaseService_DeleteAsset_Call struct {
149+ *mock.Call
150+}
151+
152+// DeleteAsset is a helper method to define mock.On call
153+// - ctx context.Context
154+// - p domain.Principal
155+// - repo domain.RepoName
156+// - assetID int64
157+func (_e *ReleaseService_Expecter) DeleteAsset(ctx any, p any, repo any, assetID any) *ReleaseService_DeleteAsset_Call {
158+ return &ReleaseService_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, repo, assetID)}
159+}
160+
161+func (_c *ReleaseService_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64)) *ReleaseService_DeleteAsset_Call {
162+ _c.Call.Run(func(args mock.Arguments) {
163+ var arg0 context.Context
164+ if args[0] != nil {
165+ arg0 = args[0].(context.Context)
166+ }
167+ var arg1 domain.Principal
168+ if args[1] != nil {
169+ arg1 = args[1].(domain.Principal)
170+ }
171+ var arg2 domain.RepoName
172+ if args[2] != nil {
173+ arg2 = args[2].(domain.RepoName)
174+ }
175+ var arg3 int64
176+ if args[3] != nil {
177+ arg3 = args[3].(int64)
178+ }
179+ run(
180+ arg0,
181+ arg1,
182+ arg2,
183+ arg3,
184+ )
185+ })
186+ return _c
187+}
188+
189+func (_c *ReleaseService_DeleteAsset_Call) Return(err error) *ReleaseService_DeleteAsset_Call {
190+ _c.Call.Return(err)
191+ return _c
192+}
193+
194+func (_c *ReleaseService_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64) error) *ReleaseService_DeleteAsset_Call {
195+ _c.Call.Return(run)
196+ return _c
197+}
198+
199+// DeleteRelease provides a mock function for the type ReleaseService
200+func (_mock *ReleaseService) DeleteRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error {
201+ ret := _mock.Called(ctx, p, repo, id)
202+
203+ if len(ret) == 0 {
204+ panic("no return value specified for DeleteRelease")
205+ }
206+
207+ var r0 error
208+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
209+ r0 = returnFunc(ctx, p, repo, id)
210+ } else {
211+ r0 = ret.Error(0)
212+ }
213+ return r0
214+}
215+
216+// ReleaseService_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease'
217+type ReleaseService_DeleteRelease_Call struct {
218+ *mock.Call
219+}
220+
221+// DeleteRelease is a helper method to define mock.On call
222+// - ctx context.Context
223+// - p domain.Principal
224+// - repo domain.RepoName
225+// - id int64
226+func (_e *ReleaseService_Expecter) DeleteRelease(ctx any, p any, repo any, id any) *ReleaseService_DeleteRelease_Call {
227+ return &ReleaseService_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, repo, id)}
228+}
229+
230+func (_c *ReleaseService_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64)) *ReleaseService_DeleteRelease_Call {
231+ _c.Call.Run(func(args mock.Arguments) {
232+ var arg0 context.Context
233+ if args[0] != nil {
234+ arg0 = args[0].(context.Context)
235+ }
236+ var arg1 domain.Principal
237+ if args[1] != nil {
238+ arg1 = args[1].(domain.Principal)
239+ }
240+ var arg2 domain.RepoName
241+ if args[2] != nil {
242+ arg2 = args[2].(domain.RepoName)
243+ }
244+ var arg3 int64
245+ if args[3] != nil {
246+ arg3 = args[3].(int64)
247+ }
248+ run(
249+ arg0,
250+ arg1,
251+ arg2,
252+ arg3,
253+ )
254+ })
255+ return _c
256+}
257+
258+func (_c *ReleaseService_DeleteRelease_Call) Return(err error) *ReleaseService_DeleteRelease_Call {
259+ _c.Call.Return(err)
260+ return _c
261+}
262+
263+func (_c *ReleaseService_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error) *ReleaseService_DeleteRelease_Call {
264+ _c.Call.Return(run)
265+ return _c
266+}
267+
268+// DownloadAsset provides a mock function for the type ReleaseService
269+func (_mock *ReleaseService) DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset int64, length int64, send func(ports.AssetChunk) error) error {
270+ ret := _mock.Called(ctx, p, repo, ref, offset, length, send)
271+
272+ if len(ret) == 0 {
273+ panic("no return value specified for DownloadAsset")
274+ }
275+
276+ var r0 error
277+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef, int64, int64, func(ports.AssetChunk) error) error); ok {
278+ r0 = returnFunc(ctx, p, repo, ref, offset, length, send)
279+ } else {
280+ r0 = ret.Error(0)
281+ }
282+ return r0
283+}
284+
285+// ReleaseService_DownloadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DownloadAsset'
286+type ReleaseService_DownloadAsset_Call struct {
287+ *mock.Call
288+}
289+
290+// DownloadAsset is a helper method to define mock.On call
291+// - ctx context.Context
292+// - p domain.Principal
293+// - repo domain.RepoName
294+// - ref ports.AssetRef
295+// - offset int64
296+// - length int64
297+// - send func(ports.AssetChunk) error
298+func (_e *ReleaseService_Expecter) DownloadAsset(ctx any, p any, repo any, ref any, offset any, length any, send any) *ReleaseService_DownloadAsset_Call {
299+ return &ReleaseService_DownloadAsset_Call{Call: _e.mock.On("DownloadAsset", ctx, p, repo, ref, offset, length, send)}
300+}
301+
302+func (_c *ReleaseService_DownloadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset int64, length int64, send func(ports.AssetChunk) error)) *ReleaseService_DownloadAsset_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.Principal
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.Principal)
311+ }
312+ var arg2 domain.RepoName
313+ if args[2] != nil {
314+ arg2 = args[2].(domain.RepoName)
315+ }
316+ var arg3 ports.AssetRef
317+ if args[3] != nil {
318+ arg3 = args[3].(ports.AssetRef)
319+ }
320+ var arg4 int64
321+ if args[4] != nil {
322+ arg4 = args[4].(int64)
323+ }
324+ var arg5 int64
325+ if args[5] != nil {
326+ arg5 = args[5].(int64)
327+ }
328+ var arg6 func(ports.AssetChunk) error
329+ if args[6] != nil {
330+ arg6 = args[6].(func(ports.AssetChunk) error)
331+ }
332+ run(
333+ arg0,
334+ arg1,
335+ arg2,
336+ arg3,
337+ arg4,
338+ arg5,
339+ arg6,
340+ )
341+ })
342+ return _c
343+}
344+
345+func (_c *ReleaseService_DownloadAsset_Call) Return(err error) *ReleaseService_DownloadAsset_Call {
346+ _c.Call.Return(err)
347+ return _c
348+}
349+
350+func (_c *ReleaseService_DownloadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset int64, length int64, send func(ports.AssetChunk) error) error) *ReleaseService_DownloadAsset_Call {
351+ _c.Call.Return(run)
352+ return _c
353+}
354+
355+// GetAsset provides a mock function for the type ReleaseService
356+func (_mock *ReleaseService) GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
357+ ret := _mock.Called(ctx, p, repo, ref)
358+
359+ if len(ret) == 0 {
360+ panic("no return value specified for GetAsset")
361+ }
362+
363+ var r0 domain.ReleaseAsset
364+ var r1 error
365+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef) (domain.ReleaseAsset, error)); ok {
366+ return returnFunc(ctx, p, repo, ref)
367+ }
368+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef) domain.ReleaseAsset); ok {
369+ r0 = returnFunc(ctx, p, repo, ref)
370+ } else {
371+ r0 = ret.Get(0).(domain.ReleaseAsset)
372+ }
373+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef) error); ok {
374+ r1 = returnFunc(ctx, p, repo, ref)
375+ } else {
376+ r1 = ret.Error(1)
377+ }
378+ return r0, r1
379+}
380+
381+// ReleaseService_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset'
382+type ReleaseService_GetAsset_Call struct {
383+ *mock.Call
384+}
385+
386+// GetAsset is a helper method to define mock.On call
387+// - ctx context.Context
388+// - p domain.Principal
389+// - repo domain.RepoName
390+// - ref ports.AssetRef
391+func (_e *ReleaseService_Expecter) GetAsset(ctx any, p any, repo any, ref any) *ReleaseService_GetAsset_Call {
392+ return &ReleaseService_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, repo, ref)}
393+}
394+
395+func (_c *ReleaseService_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef)) *ReleaseService_GetAsset_Call {
396+ _c.Call.Run(func(args mock.Arguments) {
397+ var arg0 context.Context
398+ if args[0] != nil {
399+ arg0 = args[0].(context.Context)
400+ }
401+ var arg1 domain.Principal
402+ if args[1] != nil {
403+ arg1 = args[1].(domain.Principal)
404+ }
405+ var arg2 domain.RepoName
406+ if args[2] != nil {
407+ arg2 = args[2].(domain.RepoName)
408+ }
409+ var arg3 ports.AssetRef
410+ if args[3] != nil {
411+ arg3 = args[3].(ports.AssetRef)
412+ }
413+ run(
414+ arg0,
415+ arg1,
416+ arg2,
417+ arg3,
418+ )
419+ })
420+ return _c
421+}
422+
423+func (_c *ReleaseService_GetAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseService_GetAsset_Call {
424+ _c.Call.Return(releaseAsset, err)
425+ return _c
426+}
427+
428+func (_c *ReleaseService_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error)) *ReleaseService_GetAsset_Call {
429+ _c.Call.Return(run)
430+ return _c
431+}
432+
433+// GetRelease provides a mock function for the type ReleaseService
434+func (_mock *ReleaseService) GetRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
435+ ret := _mock.Called(ctx, p, repo, ref)
436+
437+ if len(ret) == 0 {
438+ panic("no return value specified for GetRelease")
439+ }
440+
441+ var r0 domain.Release
442+ var r1 error
443+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ReleaseRef) (domain.Release, error)); ok {
444+ return returnFunc(ctx, p, repo, ref)
445+ }
446+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ReleaseRef) domain.Release); ok {
447+ r0 = returnFunc(ctx, p, repo, ref)
448+ } else {
449+ r0 = ret.Get(0).(domain.Release)
450+ }
451+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ReleaseRef) error); ok {
452+ r1 = returnFunc(ctx, p, repo, ref)
453+ } else {
454+ r1 = ret.Error(1)
455+ }
456+ return r0, r1
457+}
458+
459+// ReleaseService_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease'
460+type ReleaseService_GetRelease_Call struct {
461+ *mock.Call
462+}
463+
464+// GetRelease is a helper method to define mock.On call
465+// - ctx context.Context
466+// - p domain.Principal
467+// - repo domain.RepoName
468+// - ref ports.ReleaseRef
469+func (_e *ReleaseService_Expecter) GetRelease(ctx any, p any, repo any, ref any) *ReleaseService_GetRelease_Call {
470+ return &ReleaseService_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, repo, ref)}
471+}
472+
473+func (_c *ReleaseService_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.ReleaseRef)) *ReleaseService_GetRelease_Call {
474+ _c.Call.Run(func(args mock.Arguments) {
475+ var arg0 context.Context
476+ if args[0] != nil {
477+ arg0 = args[0].(context.Context)
478+ }
479+ var arg1 domain.Principal
480+ if args[1] != nil {
481+ arg1 = args[1].(domain.Principal)
482+ }
483+ var arg2 domain.RepoName
484+ if args[2] != nil {
485+ arg2 = args[2].(domain.RepoName)
486+ }
487+ var arg3 ports.ReleaseRef
488+ if args[3] != nil {
489+ arg3 = args[3].(ports.ReleaseRef)
490+ }
491+ run(
492+ arg0,
493+ arg1,
494+ arg2,
495+ arg3,
496+ )
497+ })
498+ return _c
499+}
500+
501+func (_c *ReleaseService_GetRelease_Call) Return(release domain.Release, err error) *ReleaseService_GetRelease_Call {
502+ _c.Call.Return(release, err)
503+ return _c
504+}
505+
506+func (_c *ReleaseService_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.ReleaseRef) (domain.Release, error)) *ReleaseService_GetRelease_Call {
507+ _c.Call.Return(run)
508+ return _c
509+}
510+
511+// ListAssets provides a mock function for the type ReleaseService
512+func (_mock *ReleaseService) ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
513+ ret := _mock.Called(ctx, p, repo, releaseID)
514+
515+ if len(ret) == 0 {
516+ panic("no return value specified for ListAssets")
517+ }
518+
519+ var r0 []domain.ReleaseAsset
520+ var r1 error
521+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ([]domain.ReleaseAsset, error)); ok {
522+ return returnFunc(ctx, p, repo, releaseID)
523+ }
524+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) []domain.ReleaseAsset); ok {
525+ r0 = returnFunc(ctx, p, repo, releaseID)
526+ } else {
527+ if ret.Get(0) != nil {
528+ r0 = ret.Get(0).([]domain.ReleaseAsset)
529+ }
530+ }
531+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
532+ r1 = returnFunc(ctx, p, repo, releaseID)
533+ } else {
534+ r1 = ret.Error(1)
535+ }
536+ return r0, r1
537+}
538+
539+// ReleaseService_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets'
540+type ReleaseService_ListAssets_Call struct {
541+ *mock.Call
542+}
543+
544+// ListAssets is a helper method to define mock.On call
545+// - ctx context.Context
546+// - p domain.Principal
547+// - repo domain.RepoName
548+// - releaseID int64
549+func (_e *ReleaseService_Expecter) ListAssets(ctx any, p any, repo any, releaseID any) *ReleaseService_ListAssets_Call {
550+ return &ReleaseService_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, repo, releaseID)}
551+}
552+
553+func (_c *ReleaseService_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64)) *ReleaseService_ListAssets_Call {
554+ _c.Call.Run(func(args mock.Arguments) {
555+ var arg0 context.Context
556+ if args[0] != nil {
557+ arg0 = args[0].(context.Context)
558+ }
559+ var arg1 domain.Principal
560+ if args[1] != nil {
561+ arg1 = args[1].(domain.Principal)
562+ }
563+ var arg2 domain.RepoName
564+ if args[2] != nil {
565+ arg2 = args[2].(domain.RepoName)
566+ }
567+ var arg3 int64
568+ if args[3] != nil {
569+ arg3 = args[3].(int64)
570+ }
571+ run(
572+ arg0,
573+ arg1,
574+ arg2,
575+ arg3,
576+ )
577+ })
578+ return _c
579+}
580+
581+func (_c *ReleaseService_ListAssets_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseService_ListAssets_Call {
582+ _c.Call.Return(releaseAssets, err)
583+ return _c
584+}
585+
586+func (_c *ReleaseService_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error)) *ReleaseService_ListAssets_Call {
587+ _c.Call.Return(run)
588+ return _c
589+}
590+
591+// ListReleases provides a mock function for the type ReleaseService
592+func (_mock *ReleaseService) ListReleases(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string) (ports.ReleasePage, error) {
593+ ret := _mock.Called(ctx, p, repo, pageSize, pageToken)
594+
595+ if len(ret) == 0 {
596+ panic("no return value specified for ListReleases")
597+ }
598+
599+ var r0 ports.ReleasePage
600+ var r1 error
601+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int, string) (ports.ReleasePage, error)); ok {
602+ return returnFunc(ctx, p, repo, pageSize, pageToken)
603+ }
604+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int, string) ports.ReleasePage); ok {
605+ r0 = returnFunc(ctx, p, repo, pageSize, pageToken)
606+ } else {
607+ r0 = ret.Get(0).(ports.ReleasePage)
608+ }
609+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int, string) error); ok {
610+ r1 = returnFunc(ctx, p, repo, pageSize, pageToken)
611+ } else {
612+ r1 = ret.Error(1)
613+ }
614+ return r0, r1
615+}
616+
617+// ReleaseService_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases'
618+type ReleaseService_ListReleases_Call struct {
619+ *mock.Call
620+}
621+
622+// ListReleases is a helper method to define mock.On call
623+// - ctx context.Context
624+// - p domain.Principal
625+// - repo domain.RepoName
626+// - pageSize int
627+// - pageToken string
628+func (_e *ReleaseService_Expecter) ListReleases(ctx any, p any, repo any, pageSize any, pageToken any) *ReleaseService_ListReleases_Call {
629+ return &ReleaseService_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, repo, pageSize, pageToken)}
630+}
631+
632+func (_c *ReleaseService_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string)) *ReleaseService_ListReleases_Call {
633+ _c.Call.Run(func(args mock.Arguments) {
634+ var arg0 context.Context
635+ if args[0] != nil {
636+ arg0 = args[0].(context.Context)
637+ }
638+ var arg1 domain.Principal
639+ if args[1] != nil {
640+ arg1 = args[1].(domain.Principal)
641+ }
642+ var arg2 domain.RepoName
643+ if args[2] != nil {
644+ arg2 = args[2].(domain.RepoName)
645+ }
646+ var arg3 int
647+ if args[3] != nil {
648+ arg3 = args[3].(int)
649+ }
650+ var arg4 string
651+ if args[4] != nil {
652+ arg4 = args[4].(string)
653+ }
654+ run(
655+ arg0,
656+ arg1,
657+ arg2,
658+ arg3,
659+ arg4,
660+ )
661+ })
662+ return _c
663+}
664+
665+func (_c *ReleaseService_ListReleases_Call) Return(releasePage ports.ReleasePage, err error) *ReleaseService_ListReleases_Call {
666+ _c.Call.Return(releasePage, err)
667+ return _c
668+}
669+
670+func (_c *ReleaseService_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string) (ports.ReleasePage, error)) *ReleaseService_ListReleases_Call {
671+ _c.Call.Return(run)
672+ return _c
673+}
674+
675+// UpdateAsset provides a mock function for the type ReleaseService
676+func (_mock *ReleaseService) UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error) {
677+ ret := _mock.Called(ctx, p, repo, assetID, params)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for UpdateAsset")
681+ }
682+
683+ var r0 domain.ReleaseAsset
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateAssetParams) (domain.ReleaseAsset, error)); ok {
686+ return returnFunc(ctx, p, repo, assetID, params)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateAssetParams) domain.ReleaseAsset); ok {
689+ r0 = returnFunc(ctx, p, repo, assetID, params)
690+ } else {
691+ r0 = ret.Get(0).(domain.ReleaseAsset)
692+ }
693+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateAssetParams) error); ok {
694+ r1 = returnFunc(ctx, p, repo, assetID, params)
695+ } else {
696+ r1 = ret.Error(1)
697+ }
698+ return r0, r1
699+}
700+
701+// ReleaseService_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset'
702+type ReleaseService_UpdateAsset_Call struct {
703+ *mock.Call
704+}
705+
706+// UpdateAsset is a helper method to define mock.On call
707+// - ctx context.Context
708+// - p domain.Principal
709+// - repo domain.RepoName
710+// - assetID int64
711+// - params ports.UpdateAssetParams
712+func (_e *ReleaseService_Expecter) UpdateAsset(ctx any, p any, repo any, assetID any, params any) *ReleaseService_UpdateAsset_Call {
713+ return &ReleaseService_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, repo, assetID, params)}
714+}
715+
716+func (_c *ReleaseService_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params ports.UpdateAssetParams)) *ReleaseService_UpdateAsset_Call {
717+ _c.Call.Run(func(args mock.Arguments) {
718+ var arg0 context.Context
719+ if args[0] != nil {
720+ arg0 = args[0].(context.Context)
721+ }
722+ var arg1 domain.Principal
723+ if args[1] != nil {
724+ arg1 = args[1].(domain.Principal)
725+ }
726+ var arg2 domain.RepoName
727+ if args[2] != nil {
728+ arg2 = args[2].(domain.RepoName)
729+ }
730+ var arg3 int64
731+ if args[3] != nil {
732+ arg3 = args[3].(int64)
733+ }
734+ var arg4 ports.UpdateAssetParams
735+ if args[4] != nil {
736+ arg4 = args[4].(ports.UpdateAssetParams)
737+ }
738+ run(
739+ arg0,
740+ arg1,
741+ arg2,
742+ arg3,
743+ arg4,
744+ )
745+ })
746+ return _c
747+}
748+
749+func (_c *ReleaseService_UpdateAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseService_UpdateAsset_Call {
750+ _c.Call.Return(releaseAsset, err)
751+ return _c
752+}
753+
754+func (_c *ReleaseService_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error)) *ReleaseService_UpdateAsset_Call {
755+ _c.Call.Return(run)
756+ return _c
757+}
758+
759+// UpdateRelease provides a mock function for the type ReleaseService
760+func (_mock *ReleaseService) UpdateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error) {
761+ ret := _mock.Called(ctx, p, repo, id, params)
762+
763+ if len(ret) == 0 {
764+ panic("no return value specified for UpdateRelease")
765+ }
766+
767+ var r0 domain.Release
768+ var r1 error
769+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateReleaseParams) (domain.Release, error)); ok {
770+ return returnFunc(ctx, p, repo, id, params)
771+ }
772+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateReleaseParams) domain.Release); ok {
773+ r0 = returnFunc(ctx, p, repo, id, params)
774+ } else {
775+ r0 = ret.Get(0).(domain.Release)
776+ }
777+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateReleaseParams) error); ok {
778+ r1 = returnFunc(ctx, p, repo, id, params)
779+ } else {
780+ r1 = ret.Error(1)
781+ }
782+ return r0, r1
783+}
784+
785+// ReleaseService_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease'
786+type ReleaseService_UpdateRelease_Call struct {
787+ *mock.Call
788+}
789+
790+// UpdateRelease is a helper method to define mock.On call
791+// - ctx context.Context
792+// - p domain.Principal
793+// - repo domain.RepoName
794+// - id int64
795+// - params ports.UpdateReleaseParams
796+func (_e *ReleaseService_Expecter) UpdateRelease(ctx any, p any, repo any, id any, params any) *ReleaseService_UpdateRelease_Call {
797+ return &ReleaseService_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, repo, id, params)}
798+}
799+
800+func (_c *ReleaseService_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateReleaseParams)) *ReleaseService_UpdateRelease_Call {
801+ _c.Call.Run(func(args mock.Arguments) {
802+ var arg0 context.Context
803+ if args[0] != nil {
804+ arg0 = args[0].(context.Context)
805+ }
806+ var arg1 domain.Principal
807+ if args[1] != nil {
808+ arg1 = args[1].(domain.Principal)
809+ }
810+ var arg2 domain.RepoName
811+ if args[2] != nil {
812+ arg2 = args[2].(domain.RepoName)
813+ }
814+ var arg3 int64
815+ if args[3] != nil {
816+ arg3 = args[3].(int64)
817+ }
818+ var arg4 ports.UpdateReleaseParams
819+ if args[4] != nil {
820+ arg4 = args[4].(ports.UpdateReleaseParams)
821+ }
822+ run(
823+ arg0,
824+ arg1,
825+ arg2,
826+ arg3,
827+ arg4,
828+ )
829+ })
830+ return _c
831+}
832+
833+func (_c *ReleaseService_UpdateRelease_Call) Return(release domain.Release, err error) *ReleaseService_UpdateRelease_Call {
834+ _c.Call.Return(release, err)
835+ return _c
836+}
837+
838+func (_c *ReleaseService_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error)) *ReleaseService_UpdateRelease_Call {
839+ _c.Call.Return(run)
840+ return _c
841+}
842+
843+// UploadAsset provides a mock function for the type ReleaseService
844+func (_mock *ReleaseService) UploadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.UploadAssetParams, body io.Reader) (domain.ReleaseAsset, error) {
845+ ret := _mock.Called(ctx, p, repo, params, body)
846+
847+ if len(ret) == 0 {
848+ panic("no return value specified for UploadAsset")
849+ }
850+
851+ var r0 domain.ReleaseAsset
852+ var r1 error
853+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UploadAssetParams, io.Reader) (domain.ReleaseAsset, error)); ok {
854+ return returnFunc(ctx, p, repo, params, body)
855+ }
856+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UploadAssetParams, io.Reader) domain.ReleaseAsset); ok {
857+ r0 = returnFunc(ctx, p, repo, params, body)
858+ } else {
859+ r0 = ret.Get(0).(domain.ReleaseAsset)
860+ }
861+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.UploadAssetParams, io.Reader) error); ok {
862+ r1 = returnFunc(ctx, p, repo, params, body)
863+ } else {
864+ r1 = ret.Error(1)
865+ }
866+ return r0, r1
867+}
868+
869+// ReleaseService_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset'
870+type ReleaseService_UploadAsset_Call struct {
871+ *mock.Call
872+}
873+
874+// UploadAsset is a helper method to define mock.On call
875+// - ctx context.Context
876+// - p domain.Principal
877+// - repo domain.RepoName
878+// - params ports.UploadAssetParams
879+// - body io.Reader
880+func (_e *ReleaseService_Expecter) UploadAsset(ctx any, p any, repo any, params any, body any) *ReleaseService_UploadAsset_Call {
881+ return &ReleaseService_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, repo, params, body)}
882+}
883+
884+func (_c *ReleaseService_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.UploadAssetParams, body io.Reader)) *ReleaseService_UploadAsset_Call {
885+ _c.Call.Run(func(args mock.Arguments) {
886+ var arg0 context.Context
887+ if args[0] != nil {
888+ arg0 = args[0].(context.Context)
889+ }
890+ var arg1 domain.Principal
891+ if args[1] != nil {
892+ arg1 = args[1].(domain.Principal)
893+ }
894+ var arg2 domain.RepoName
895+ if args[2] != nil {
896+ arg2 = args[2].(domain.RepoName)
897+ }
898+ var arg3 ports.UploadAssetParams
899+ if args[3] != nil {
900+ arg3 = args[3].(ports.UploadAssetParams)
901+ }
902+ var arg4 io.Reader
903+ if args[4] != nil {
904+ arg4 = args[4].(io.Reader)
905+ }
906+ run(
907+ arg0,
908+ arg1,
909+ arg2,
910+ arg3,
911+ arg4,
912+ )
913+ })
914+ return _c
915+}
916+
917+func (_c *ReleaseService_UploadAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseService_UploadAsset_Call {
918+ _c.Call.Return(releaseAsset, err)
919+ return _c
920+}
921+
922+func (_c *ReleaseService_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.UploadAssetParams, body io.Reader) (domain.ReleaseAsset, error)) *ReleaseService_UploadAsset_Call {
923+ _c.Call.Return(run)
924+ return _c
925+}
926+
927+// Usage provides a mock function for the type ReleaseService
928+func (_mock *ReleaseService) Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error) {
929+ ret := _mock.Called(ctx, p, repo)
930+
931+ if len(ret) == 0 {
932+ panic("no return value specified for Usage")
933+ }
934+
935+ var r0 ports.ReleaseUsage
936+ var r1 error
937+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.ReleaseUsage, error)); ok {
938+ return returnFunc(ctx, p, repo)
939+ }
940+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.ReleaseUsage); ok {
941+ r0 = returnFunc(ctx, p, repo)
942+ } else {
943+ r0 = ret.Get(0).(ports.ReleaseUsage)
944+ }
945+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
946+ r1 = returnFunc(ctx, p, repo)
947+ } else {
948+ r1 = ret.Error(1)
949+ }
950+ return r0, r1
951+}
952+
953+// ReleaseService_Usage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Usage'
954+type ReleaseService_Usage_Call struct {
955+ *mock.Call
956+}
957+
958+// Usage is a helper method to define mock.On call
959+// - ctx context.Context
960+// - p domain.Principal
961+// - repo domain.RepoName
962+func (_e *ReleaseService_Expecter) Usage(ctx any, p any, repo any) *ReleaseService_Usage_Call {
963+ return &ReleaseService_Usage_Call{Call: _e.mock.On("Usage", ctx, p, repo)}
964+}
965+
966+func (_c *ReleaseService_Usage_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *ReleaseService_Usage_Call {
967+ _c.Call.Run(func(args mock.Arguments) {
968+ var arg0 context.Context
969+ if args[0] != nil {
970+ arg0 = args[0].(context.Context)
971+ }
972+ var arg1 domain.Principal
973+ if args[1] != nil {
974+ arg1 = args[1].(domain.Principal)
975+ }
976+ var arg2 domain.RepoName
977+ if args[2] != nil {
978+ arg2 = args[2].(domain.RepoName)
979+ }
980+ run(
981+ arg0,
982+ arg1,
983+ arg2,
984+ )
985+ })
986+ return _c
987+}
988+
989+func (_c *ReleaseService_Usage_Call) Return(releaseUsage ports.ReleaseUsage, err error) *ReleaseService_Usage_Call {
990+ _c.Call.Return(releaseUsage, err)
991+ return _c
992+}
993+
994+func (_c *ReleaseService_Usage_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error)) *ReleaseService_Usage_Call {
995+ _c.Call.Return(run)
996+ return _c
997+}
A internal/ports/mocks/ReleaseStore.go +796 -0
@@ -0,0 +1,796 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewReleaseStore creates a new instance of ReleaseStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewReleaseStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *ReleaseStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &ReleaseStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// ReleaseStore is an autogenerated mock type for the ReleaseStore type
39+type ReleaseStore struct {
40+ mock.Mock
41+}
42+
43+type ReleaseStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *ReleaseStore) EXPECT() *ReleaseStore_Expecter {
48+ return &ReleaseStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// AllAssets provides a mock function for the type ReleaseStore
52+func (_mock *ReleaseStore) AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AllAssets")
57+ }
58+
59+ var r0 []domain.ReleaseAsset
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.ReleaseAsset, error)); ok {
62+ return returnFunc(ctx)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.ReleaseAsset); ok {
65+ r0 = returnFunc(ctx)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).([]domain.ReleaseAsset)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
72+ r1 = returnFunc(ctx)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// ReleaseStore_AllAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllAssets'
80+type ReleaseStore_AllAssets_Call struct {
81+ *mock.Call
82+}
83+
84+// AllAssets is a helper method to define mock.On call
85+// - ctx context.Context
86+func (_e *ReleaseStore_Expecter) AllAssets(ctx any) *ReleaseStore_AllAssets_Call {
87+ return &ReleaseStore_AllAssets_Call{Call: _e.mock.On("AllAssets", ctx)}
88+}
89+
90+func (_c *ReleaseStore_AllAssets_Call) Run(run func(ctx context.Context)) *ReleaseStore_AllAssets_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ run(
97+ arg0,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *ReleaseStore_AllAssets_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseStore_AllAssets_Call {
104+ _c.Call.Return(releaseAssets, err)
105+ return _c
106+}
107+
108+func (_c *ReleaseStore_AllAssets_Call) RunAndReturn(run func(ctx context.Context) ([]domain.ReleaseAsset, error)) *ReleaseStore_AllAssets_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
113+// ApplyReleases provides a mock function for the type ReleaseStore
114+func (_mock *ReleaseStore) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) error {
115+ ret := _mock.Called(ctx, w)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for ApplyReleases")
119+ }
120+
121+ var r0 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.ReleaseWrite) error); ok {
123+ r0 = returnFunc(ctx, w)
124+ } else {
125+ r0 = ret.Error(0)
126+ }
127+ return r0
128+}
129+
130+// ReleaseStore_ApplyReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyReleases'
131+type ReleaseStore_ApplyReleases_Call struct {
132+ *mock.Call
133+}
134+
135+// ApplyReleases is a helper method to define mock.On call
136+// - ctx context.Context
137+// - w ports.ReleaseWrite
138+func (_e *ReleaseStore_Expecter) ApplyReleases(ctx any, w any) *ReleaseStore_ApplyReleases_Call {
139+ return &ReleaseStore_ApplyReleases_Call{Call: _e.mock.On("ApplyReleases", ctx, w)}
140+}
141+
142+func (_c *ReleaseStore_ApplyReleases_Call) Run(run func(ctx context.Context, w ports.ReleaseWrite)) *ReleaseStore_ApplyReleases_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 ports.ReleaseWrite
149+ if args[1] != nil {
150+ arg1 = args[1].(ports.ReleaseWrite)
151+ }
152+ run(
153+ arg0,
154+ arg1,
155+ )
156+ })
157+ return _c
158+}
159+
160+func (_c *ReleaseStore_ApplyReleases_Call) Return(err error) *ReleaseStore_ApplyReleases_Call {
161+ _c.Call.Return(err)
162+ return _c
163+}
164+
165+func (_c *ReleaseStore_ApplyReleases_Call) RunAndReturn(run func(ctx context.Context, w ports.ReleaseWrite) error) *ReleaseStore_ApplyReleases_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// GetAsset provides a mock function for the type ReleaseStore
171+func (_mock *ReleaseStore) GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) {
172+ ret := _mock.Called(ctx, id)
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for GetAsset")
176+ }
177+
178+ var r0 domain.ReleaseAsset
179+ var r1 error
180+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) (domain.ReleaseAsset, error)); ok {
181+ return returnFunc(ctx, id)
182+ }
183+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) domain.ReleaseAsset); ok {
184+ r0 = returnFunc(ctx, id)
185+ } else {
186+ r0 = ret.Get(0).(domain.ReleaseAsset)
187+ }
188+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64) error); ok {
189+ r1 = returnFunc(ctx, id)
190+ } else {
191+ r1 = ret.Error(1)
192+ }
193+ return r0, r1
194+}
195+
196+// ReleaseStore_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset'
197+type ReleaseStore_GetAsset_Call struct {
198+ *mock.Call
199+}
200+
201+// GetAsset is a helper method to define mock.On call
202+// - ctx context.Context
203+// - id int64
204+func (_e *ReleaseStore_Expecter) GetAsset(ctx any, id any) *ReleaseStore_GetAsset_Call {
205+ return &ReleaseStore_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, id)}
206+}
207+
208+func (_c *ReleaseStore_GetAsset_Call) Run(run func(ctx context.Context, id int64)) *ReleaseStore_GetAsset_Call {
209+ _c.Call.Run(func(args mock.Arguments) {
210+ var arg0 context.Context
211+ if args[0] != nil {
212+ arg0 = args[0].(context.Context)
213+ }
214+ var arg1 int64
215+ if args[1] != nil {
216+ arg1 = args[1].(int64)
217+ }
218+ run(
219+ arg0,
220+ arg1,
221+ )
222+ })
223+ return _c
224+}
225+
226+func (_c *ReleaseStore_GetAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseStore_GetAsset_Call {
227+ _c.Call.Return(releaseAsset, err)
228+ return _c
229+}
230+
231+func (_c *ReleaseStore_GetAsset_Call) RunAndReturn(run func(ctx context.Context, id int64) (domain.ReleaseAsset, error)) *ReleaseStore_GetAsset_Call {
232+ _c.Call.Return(run)
233+ return _c
234+}
235+
236+// GetAssetByName provides a mock function for the type ReleaseStore
237+func (_mock *ReleaseStore) GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) {
238+ ret := _mock.Called(ctx, repo, releaseID, name)
239+
240+ if len(ret) == 0 {
241+ panic("no return value specified for GetAssetByName")
242+ }
243+
244+ var r0 domain.ReleaseAsset
245+ var r1 error
246+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, string) (domain.ReleaseAsset, error)); ok {
247+ return returnFunc(ctx, repo, releaseID, name)
248+ }
249+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, string) domain.ReleaseAsset); ok {
250+ r0 = returnFunc(ctx, repo, releaseID, name)
251+ } else {
252+ r0 = ret.Get(0).(domain.ReleaseAsset)
253+ }
254+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, string) error); ok {
255+ r1 = returnFunc(ctx, repo, releaseID, name)
256+ } else {
257+ r1 = ret.Error(1)
258+ }
259+ return r0, r1
260+}
261+
262+// ReleaseStore_GetAssetByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAssetByName'
263+type ReleaseStore_GetAssetByName_Call struct {
264+ *mock.Call
265+}
266+
267+// GetAssetByName is a helper method to define mock.On call
268+// - ctx context.Context
269+// - repo domain.RepoName
270+// - releaseID int64
271+// - name string
272+func (_e *ReleaseStore_Expecter) GetAssetByName(ctx any, repo any, releaseID any, name any) *ReleaseStore_GetAssetByName_Call {
273+ return &ReleaseStore_GetAssetByName_Call{Call: _e.mock.On("GetAssetByName", ctx, repo, releaseID, name)}
274+}
275+
276+func (_c *ReleaseStore_GetAssetByName_Call) Run(run func(ctx context.Context, repo domain.RepoName, releaseID int64, name string)) *ReleaseStore_GetAssetByName_Call {
277+ _c.Call.Run(func(args mock.Arguments) {
278+ var arg0 context.Context
279+ if args[0] != nil {
280+ arg0 = args[0].(context.Context)
281+ }
282+ var arg1 domain.RepoName
283+ if args[1] != nil {
284+ arg1 = args[1].(domain.RepoName)
285+ }
286+ var arg2 int64
287+ if args[2] != nil {
288+ arg2 = args[2].(int64)
289+ }
290+ var arg3 string
291+ if args[3] != nil {
292+ arg3 = args[3].(string)
293+ }
294+ run(
295+ arg0,
296+ arg1,
297+ arg2,
298+ arg3,
299+ )
300+ })
301+ return _c
302+}
303+
304+func (_c *ReleaseStore_GetAssetByName_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseStore_GetAssetByName_Call {
305+ _c.Call.Return(releaseAsset, err)
306+ return _c
307+}
308+
309+func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error)) *ReleaseStore_GetAssetByName_Call {
310+ _c.Call.Return(run)
311+ return _c
312+}
313+
314+// GetRelease provides a mock function for the type ReleaseStore
315+func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
316+ ret := _mock.Called(ctx, repo, id)
317+
318+ if len(ret) == 0 {
319+ panic("no return value specified for GetRelease")
320+ }
321+
322+ var r0 domain.Release
323+ var r1 error
324+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Release, error)); ok {
325+ return returnFunc(ctx, repo, id)
326+ }
327+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Release); ok {
328+ r0 = returnFunc(ctx, repo, id)
329+ } else {
330+ r0 = ret.Get(0).(domain.Release)
331+ }
332+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
333+ r1 = returnFunc(ctx, repo, id)
334+ } else {
335+ r1 = ret.Error(1)
336+ }
337+ return r0, r1
338+}
339+
340+// ReleaseStore_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease'
341+type ReleaseStore_GetRelease_Call struct {
342+ *mock.Call
343+}
344+
345+// GetRelease is a helper method to define mock.On call
346+// - ctx context.Context
347+// - repo domain.RepoName
348+// - id int64
349+func (_e *ReleaseStore_Expecter) GetRelease(ctx any, repo any, id any) *ReleaseStore_GetRelease_Call {
350+ return &ReleaseStore_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, repo, id)}
351+}
352+
353+func (_c *ReleaseStore_GetRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName, id int64)) *ReleaseStore_GetRelease_Call {
354+ _c.Call.Run(func(args mock.Arguments) {
355+ var arg0 context.Context
356+ if args[0] != nil {
357+ arg0 = args[0].(context.Context)
358+ }
359+ var arg1 domain.RepoName
360+ if args[1] != nil {
361+ arg1 = args[1].(domain.RepoName)
362+ }
363+ var arg2 int64
364+ if args[2] != nil {
365+ arg2 = args[2].(int64)
366+ }
367+ run(
368+ arg0,
369+ arg1,
370+ arg2,
371+ )
372+ })
373+ return _c
374+}
375+
376+func (_c *ReleaseStore_GetRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetRelease_Call {
377+ _c.Call.Return(release, err)
378+ return _c
379+}
380+
381+func (_c *ReleaseStore_GetRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)) *ReleaseStore_GetRelease_Call {
382+ _c.Call.Return(run)
383+ return _c
384+}
385+
386+// GetReleaseByTag provides a mock function for the type ReleaseStore
387+func (_mock *ReleaseStore) GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) {
388+ ret := _mock.Called(ctx, repo, tag)
389+
390+ if len(ret) == 0 {
391+ panic("no return value specified for GetReleaseByTag")
392+ }
393+
394+ var r0 domain.Release
395+ var r1 error
396+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (domain.Release, error)); ok {
397+ return returnFunc(ctx, repo, tag)
398+ }
399+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) domain.Release); ok {
400+ r0 = returnFunc(ctx, repo, tag)
401+ } else {
402+ r0 = ret.Get(0).(domain.Release)
403+ }
404+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
405+ r1 = returnFunc(ctx, repo, tag)
406+ } else {
407+ r1 = ret.Error(1)
408+ }
409+ return r0, r1
410+}
411+
412+// ReleaseStore_GetReleaseByTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetReleaseByTag'
413+type ReleaseStore_GetReleaseByTag_Call struct {
414+ *mock.Call
415+}
416+
417+// GetReleaseByTag is a helper method to define mock.On call
418+// - ctx context.Context
419+// - repo domain.RepoName
420+// - tag string
421+func (_e *ReleaseStore_Expecter) GetReleaseByTag(ctx any, repo any, tag any) *ReleaseStore_GetReleaseByTag_Call {
422+ return &ReleaseStore_GetReleaseByTag_Call{Call: _e.mock.On("GetReleaseByTag", ctx, repo, tag)}
423+}
424+
425+func (_c *ReleaseStore_GetReleaseByTag_Call) Run(run func(ctx context.Context, repo domain.RepoName, tag string)) *ReleaseStore_GetReleaseByTag_Call {
426+ _c.Call.Run(func(args mock.Arguments) {
427+ var arg0 context.Context
428+ if args[0] != nil {
429+ arg0 = args[0].(context.Context)
430+ }
431+ var arg1 domain.RepoName
432+ if args[1] != nil {
433+ arg1 = args[1].(domain.RepoName)
434+ }
435+ var arg2 string
436+ if args[2] != nil {
437+ arg2 = args[2].(string)
438+ }
439+ run(
440+ arg0,
441+ arg1,
442+ arg2,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *ReleaseStore_GetReleaseByTag_Call) Return(release domain.Release, err error) *ReleaseStore_GetReleaseByTag_Call {
449+ _c.Call.Return(release, err)
450+ return _c
451+}
452+
453+func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error)) *ReleaseStore_GetReleaseByTag_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
457+
458+// ListAssets provides a mock function for the type ReleaseStore
459+func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
460+ ret := _mock.Called(ctx, repo, releaseID)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for ListAssets")
464+ }
465+
466+ var r0 []domain.ReleaseAsset
467+ var r1 error
468+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.ReleaseAsset, error)); ok {
469+ return returnFunc(ctx, repo, releaseID)
470+ }
471+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.ReleaseAsset); ok {
472+ r0 = returnFunc(ctx, repo, releaseID)
473+ } else {
474+ if ret.Get(0) != nil {
475+ r0 = ret.Get(0).([]domain.ReleaseAsset)
476+ }
477+ }
478+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
479+ r1 = returnFunc(ctx, repo, releaseID)
480+ } else {
481+ r1 = ret.Error(1)
482+ }
483+ return r0, r1
484+}
485+
486+// ReleaseStore_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets'
487+type ReleaseStore_ListAssets_Call struct {
488+ *mock.Call
489+}
490+
491+// ListAssets is a helper method to define mock.On call
492+// - ctx context.Context
493+// - repo domain.RepoName
494+// - releaseID int64
495+func (_e *ReleaseStore_Expecter) ListAssets(ctx any, repo any, releaseID any) *ReleaseStore_ListAssets_Call {
496+ return &ReleaseStore_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, repo, releaseID)}
497+}
498+
499+func (_c *ReleaseStore_ListAssets_Call) Run(run func(ctx context.Context, repo domain.RepoName, releaseID int64)) *ReleaseStore_ListAssets_Call {
500+ _c.Call.Run(func(args mock.Arguments) {
501+ var arg0 context.Context
502+ if args[0] != nil {
503+ arg0 = args[0].(context.Context)
504+ }
505+ var arg1 domain.RepoName
506+ if args[1] != nil {
507+ arg1 = args[1].(domain.RepoName)
508+ }
509+ var arg2 int64
510+ if args[2] != nil {
511+ arg2 = args[2].(int64)
512+ }
513+ run(
514+ arg0,
515+ arg1,
516+ arg2,
517+ )
518+ })
519+ return _c
520+}
521+
522+func (_c *ReleaseStore_ListAssets_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseStore_ListAssets_Call {
523+ _c.Call.Return(releaseAssets, err)
524+ return _c
525+}
526+
527+func (_c *ReleaseStore_ListAssets_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error)) *ReleaseStore_ListAssets_Call {
528+ _c.Call.Return(run)
529+ return _c
530+}
531+
532+// ListReleases provides a mock function for the type ReleaseStore
533+func (_mock *ReleaseStore) ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) {
534+ ret := _mock.Called(ctx, repo)
535+
536+ if len(ret) == 0 {
537+ panic("no return value specified for ListReleases")
538+ }
539+
540+ var r0 []domain.Release
541+ var r1 error
542+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Release, error)); ok {
543+ return returnFunc(ctx, repo)
544+ }
545+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Release); ok {
546+ r0 = returnFunc(ctx, repo)
547+ } else {
548+ if ret.Get(0) != nil {
549+ r0 = ret.Get(0).([]domain.Release)
550+ }
551+ }
552+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
553+ r1 = returnFunc(ctx, repo)
554+ } else {
555+ r1 = ret.Error(1)
556+ }
557+ return r0, r1
558+}
559+
560+// ReleaseStore_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases'
561+type ReleaseStore_ListReleases_Call struct {
562+ *mock.Call
563+}
564+
565+// ListReleases is a helper method to define mock.On call
566+// - ctx context.Context
567+// - repo domain.RepoName
568+func (_e *ReleaseStore_Expecter) ListReleases(ctx any, repo any) *ReleaseStore_ListReleases_Call {
569+ return &ReleaseStore_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, repo)}
570+}
571+
572+func (_c *ReleaseStore_ListReleases_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_ListReleases_Call {
573+ _c.Call.Run(func(args mock.Arguments) {
574+ var arg0 context.Context
575+ if args[0] != nil {
576+ arg0 = args[0].(context.Context)
577+ }
578+ var arg1 domain.RepoName
579+ if args[1] != nil {
580+ arg1 = args[1].(domain.RepoName)
581+ }
582+ run(
583+ arg0,
584+ arg1,
585+ )
586+ })
587+ return _c
588+}
589+
590+func (_c *ReleaseStore_ListReleases_Call) Return(releases []domain.Release, err error) *ReleaseStore_ListReleases_Call {
591+ _c.Call.Return(releases, err)
592+ return _c
593+}
594+
595+func (_c *ReleaseStore_ListReleases_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Release, error)) *ReleaseStore_ListReleases_Call {
596+ _c.Call.Return(run)
597+ return _c
598+}
599+
600+// NextReleaseID provides a mock function for the type ReleaseStore
601+func (_mock *ReleaseStore) NextReleaseID(ctx context.Context) (int64, error) {
602+ ret := _mock.Called(ctx)
603+
604+ if len(ret) == 0 {
605+ panic("no return value specified for NextReleaseID")
606+ }
607+
608+ var r0 int64
609+ var r1 error
610+ if returnFunc, ok := ret.Get(0).(func(context.Context) (int64, error)); ok {
611+ return returnFunc(ctx)
612+ }
613+ if returnFunc, ok := ret.Get(0).(func(context.Context) int64); ok {
614+ r0 = returnFunc(ctx)
615+ } else {
616+ r0 = ret.Get(0).(int64)
617+ }
618+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
619+ r1 = returnFunc(ctx)
620+ } else {
621+ r1 = ret.Error(1)
622+ }
623+ return r0, r1
624+}
625+
626+// ReleaseStore_NextReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NextReleaseID'
627+type ReleaseStore_NextReleaseID_Call struct {
628+ *mock.Call
629+}
630+
631+// NextReleaseID is a helper method to define mock.On call
632+// - ctx context.Context
633+func (_e *ReleaseStore_Expecter) NextReleaseID(ctx any) *ReleaseStore_NextReleaseID_Call {
634+ return &ReleaseStore_NextReleaseID_Call{Call: _e.mock.On("NextReleaseID", ctx)}
635+}
636+
637+func (_c *ReleaseStore_NextReleaseID_Call) Run(run func(ctx context.Context)) *ReleaseStore_NextReleaseID_Call {
638+ _c.Call.Run(func(args mock.Arguments) {
639+ var arg0 context.Context
640+ if args[0] != nil {
641+ arg0 = args[0].(context.Context)
642+ }
643+ run(
644+ arg0,
645+ )
646+ })
647+ return _c
648+}
649+
650+func (_c *ReleaseStore_NextReleaseID_Call) Return(n int64, err error) *ReleaseStore_NextReleaseID_Call {
651+ _c.Call.Return(n, err)
652+ return _c
653+}
654+
655+func (_c *ReleaseStore_NextReleaseID_Call) RunAndReturn(run func(ctx context.Context) (int64, error)) *ReleaseStore_NextReleaseID_Call {
656+ _c.Call.Return(run)
657+ return _c
658+}
659+
660+// ReleaseRepo provides a mock function for the type ReleaseStore
661+func (_mock *ReleaseStore) ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) {
662+ ret := _mock.Called(ctx, id)
663+
664+ if len(ret) == 0 {
665+ panic("no return value specified for ReleaseRepo")
666+ }
667+
668+ var r0 domain.RepoName
669+ var r1 error
670+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) (domain.RepoName, error)); ok {
671+ return returnFunc(ctx, id)
672+ }
673+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) domain.RepoName); ok {
674+ r0 = returnFunc(ctx, id)
675+ } else {
676+ r0 = ret.Get(0).(domain.RepoName)
677+ }
678+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64) error); ok {
679+ r1 = returnFunc(ctx, id)
680+ } else {
681+ r1 = ret.Error(1)
682+ }
683+ return r0, r1
684+}
685+
686+// ReleaseStore_ReleaseRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReleaseRepo'
687+type ReleaseStore_ReleaseRepo_Call struct {
688+ *mock.Call
689+}
690+
691+// ReleaseRepo is a helper method to define mock.On call
692+// - ctx context.Context
693+// - id int64
694+func (_e *ReleaseStore_Expecter) ReleaseRepo(ctx any, id any) *ReleaseStore_ReleaseRepo_Call {
695+ return &ReleaseStore_ReleaseRepo_Call{Call: _e.mock.On("ReleaseRepo", ctx, id)}
696+}
697+
698+func (_c *ReleaseStore_ReleaseRepo_Call) Run(run func(ctx context.Context, id int64)) *ReleaseStore_ReleaseRepo_Call {
699+ _c.Call.Run(func(args mock.Arguments) {
700+ var arg0 context.Context
701+ if args[0] != nil {
702+ arg0 = args[0].(context.Context)
703+ }
704+ var arg1 int64
705+ if args[1] != nil {
706+ arg1 = args[1].(int64)
707+ }
708+ run(
709+ arg0,
710+ arg1,
711+ )
712+ })
713+ return _c
714+}
715+
716+func (_c *ReleaseStore_ReleaseRepo_Call) Return(repoName domain.RepoName, err error) *ReleaseStore_ReleaseRepo_Call {
717+ _c.Call.Return(repoName, err)
718+ return _c
719+}
720+
721+func (_c *ReleaseStore_ReleaseRepo_Call) RunAndReturn(run func(ctx context.Context, id int64) (domain.RepoName, error)) *ReleaseStore_ReleaseRepo_Call {
722+ _c.Call.Return(run)
723+ return _c
724+}
725+
726+// ReleaseUsage provides a mock function for the type ReleaseStore
727+func (_mock *ReleaseStore) ReleaseUsage(ctx context.Context, repo domain.RepoName) (int64, uint64, error) {
728+ ret := _mock.Called(ctx, repo)
729+
730+ if len(ret) == 0 {
731+ panic("no return value specified for ReleaseUsage")
732+ }
733+
734+ var r0 int64
735+ var r1 uint64
736+ var r2 error
737+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, uint64, error)); ok {
738+ return returnFunc(ctx, repo)
739+ }
740+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok {
741+ r0 = returnFunc(ctx, repo)
742+ } else {
743+ r0 = ret.Get(0).(int64)
744+ }
745+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) uint64); ok {
746+ r1 = returnFunc(ctx, repo)
747+ } else {
748+ r1 = ret.Get(1).(uint64)
749+ }
750+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
751+ r2 = returnFunc(ctx, repo)
752+ } else {
753+ r2 = ret.Error(2)
754+ }
755+ return r0, r1, r2
756+}
757+
758+// ReleaseStore_ReleaseUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReleaseUsage'
759+type ReleaseStore_ReleaseUsage_Call struct {
760+ *mock.Call
761+}
762+
763+// ReleaseUsage is a helper method to define mock.On call
764+// - ctx context.Context
765+// - repo domain.RepoName
766+func (_e *ReleaseStore_Expecter) ReleaseUsage(ctx any, repo any) *ReleaseStore_ReleaseUsage_Call {
767+ return &ReleaseStore_ReleaseUsage_Call{Call: _e.mock.On("ReleaseUsage", ctx, repo)}
768+}
769+
770+func (_c *ReleaseStore_ReleaseUsage_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_ReleaseUsage_Call {
771+ _c.Call.Run(func(args mock.Arguments) {
772+ var arg0 context.Context
773+ if args[0] != nil {
774+ arg0 = args[0].(context.Context)
775+ }
776+ var arg1 domain.RepoName
777+ if args[1] != nil {
778+ arg1 = args[1].(domain.RepoName)
779+ }
780+ run(
781+ arg0,
782+ arg1,
783+ )
784+ })
785+ return _c
786+}
787+
788+func (_c *ReleaseStore_ReleaseUsage_Call) Return(used int64, version uint64, err error) *ReleaseStore_ReleaseUsage_Call {
789+ _c.Call.Return(used, version, err)
790+ return _c
791+}
792+
793+func (_c *ReleaseStore_ReleaseUsage_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, uint64, error)) *ReleaseStore_ReleaseUsage_Call {
794+ _c.Call.Return(run)
795+ return _c
796+}
A internal/ports/mocks/ReleaseSweeper.go +87 -0
@@ -0,0 +1,87 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewReleaseSweeper creates a new instance of ReleaseSweeper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewReleaseSweeper(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *ReleaseSweeper {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &ReleaseSweeper{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// ReleaseSweeper is an autogenerated mock type for the ReleaseSweeper type
37+type ReleaseSweeper struct {
38+ mock.Mock
39+}
40+
41+type ReleaseSweeper_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *ReleaseSweeper) EXPECT() *ReleaseSweeper_Expecter {
46+ return &ReleaseSweeper_Expecter{mock: &_m.Mock}
47+}
48+
49+// Sweep provides a mock function for the type ReleaseSweeper
50+func (_mock *ReleaseSweeper) Sweep(ctx context.Context) {
51+ _mock.Called(ctx)
52+ return
53+}
54+
55+// ReleaseSweeper_Sweep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sweep'
56+type ReleaseSweeper_Sweep_Call struct {
57+ *mock.Call
58+}
59+
60+// Sweep is a helper method to define mock.On call
61+// - ctx context.Context
62+func (_e *ReleaseSweeper_Expecter) Sweep(ctx any) *ReleaseSweeper_Sweep_Call {
63+ return &ReleaseSweeper_Sweep_Call{Call: _e.mock.On("Sweep", ctx)}
64+}
65+
66+func (_c *ReleaseSweeper_Sweep_Call) Run(run func(ctx context.Context)) *ReleaseSweeper_Sweep_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 context.Context
69+ if args[0] != nil {
70+ arg0 = args[0].(context.Context)
71+ }
72+ run(
73+ arg0,
74+ )
75+ })
76+ return _c
77+}
78+
79+func (_c *ReleaseSweeper_Sweep_Call) Return() *ReleaseSweeper_Sweep_Call {
80+ _c.Call.Return()
81+ return _c
82+}
83+
84+func (_c *ReleaseSweeper_Sweep_Call) RunAndReturn(run func(ctx context.Context)) *ReleaseSweeper_Sweep_Call {
85+ _c.Run(run)
86+ return _c
87+}
A internal/ports/mocks/ReleaseTagTrigger.go +100 -0
@@ -0,0 +1,100 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewReleaseTagTrigger creates a new instance of ReleaseTagTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewReleaseTagTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *ReleaseTagTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &ReleaseTagTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// ReleaseTagTrigger is an autogenerated mock type for the ReleaseTagTrigger type
38+type ReleaseTagTrigger struct {
39+ mock.Mock
40+}
41+
42+type ReleaseTagTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *ReleaseTagTrigger) EXPECT() *ReleaseTagTrigger_Expecter {
47+ return &ReleaseTagTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnRefsMoved provides a mock function for the type ReleaseTagTrigger
51+func (_mock *ReleaseTagTrigger) OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// ReleaseTagTrigger_OnRefsMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnRefsMoved'
57+type ReleaseTagTrigger_OnRefsMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnRefsMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *ReleaseTagTrigger_Expecter) OnRefsMoved(ctx any, repo any, updates any) *ReleaseTagTrigger_OnRefsMoved_Call {
66+ return &ReleaseTagTrigger_OnRefsMoved_Call{Call: _e.mock.On("OnRefsMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *ReleaseTagTrigger_OnRefsMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *ReleaseTagTrigger_OnRefsMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *ReleaseTagTrigger_OnRefsMoved_Call) Return() *ReleaseTagTrigger_OnRefsMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *ReleaseTagTrigger_OnRefsMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *ReleaseTagTrigger_OnRefsMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
A internal/ports/mocks/ReleaseTagWriter.go +117 -0
@@ -0,0 +1,117 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewReleaseTagWriter creates a new instance of ReleaseTagWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewReleaseTagWriter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *ReleaseTagWriter {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &ReleaseTagWriter{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// ReleaseTagWriter is an autogenerated mock type for the ReleaseTagWriter type
38+type ReleaseTagWriter struct {
39+ mock.Mock
40+}
41+
42+type ReleaseTagWriter_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *ReleaseTagWriter) EXPECT() *ReleaseTagWriter_Expecter {
47+ return &ReleaseTagWriter_Expecter{mock: &_m.Mock}
48+}
49+
50+// CreateTag provides a mock function for the type ReleaseTagWriter
51+func (_mock *ReleaseTagWriter) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
52+ ret := _mock.Called(ctx, repo, tag, target)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CreateTag")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, domain.ObjectID) error); ok {
60+ r0 = returnFunc(ctx, repo, tag, target)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// ReleaseTagWriter_CreateTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTag'
68+type ReleaseTagWriter_CreateTag_Call struct {
69+ *mock.Call
70+}
71+
72+// CreateTag is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - tag string
76+// - target domain.ObjectID
77+func (_e *ReleaseTagWriter_Expecter) CreateTag(ctx any, repo any, tag any, target any) *ReleaseTagWriter_CreateTag_Call {
78+ return &ReleaseTagWriter_CreateTag_Call{Call: _e.mock.On("CreateTag", ctx, repo, tag, target)}
79+}
80+
81+func (_c *ReleaseTagWriter_CreateTag_Call) Run(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID)) *ReleaseTagWriter_CreateTag_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.RepoName
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.RepoName)
90+ }
91+ var arg2 string
92+ if args[2] != nil {
93+ arg2 = args[2].(string)
94+ }
95+ var arg3 domain.ObjectID
96+ if args[3] != nil {
97+ arg3 = args[3].(domain.ObjectID)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *ReleaseTagWriter_CreateTag_Call) Return(err error) *ReleaseTagWriter_CreateTag_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *ReleaseTagWriter_CreateTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error) *ReleaseTagWriter_CreateTag_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
A internal/ports/release.go +119 -0
@@ -0,0 +1,119 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "bnema.dev/ermite/internal/domain"
7+ "context"
8+ "io"
9+)
10+
11+// ReleaseWrite atomically changes one repository's metadata. Every write
12+// requires UsageVersion (zero requires absence), and each record's Version.
13+// Used includes both uploading and uploaded assets.
14+type ReleaseWrite struct {
15+ Repo domain.RepoName
16+ Used int64
17+ UsageVersion uint64
18+ PutReleases, DeleteReleases []domain.Release
19+ PutAssets, DeleteAssets []domain.ReleaseAsset
20+}
21+
22+// ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.
23+type ReleaseStore interface {
24+ NextReleaseID(ctx context.Context) (int64, error)
25+ ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)
26+ GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)
27+ GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error)
28+ ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error)
29+ ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error)
30+ GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error)
31+ GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error)
32+ ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error)
33+ AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error)
34+ ApplyReleases(ctx context.Context, w ReleaseWrite) error
35+}
36+
37+// ReleaseLimits defaults to domain.DefaultReleaseAssetMax (ERMITE_RELEASE_ASSET_MAX),
38+// domain.DefaultReleaseAssetsMax (ERMITE_RELEASE_ASSETS_MAX), and
39+// domain.DefaultReleaseQuota (ERMITE_RELEASE_QUOTA).
40+type ReleaseLimits struct{ AssetMax, AssetsMax, Quota int64 }
41+type CreateReleaseParams struct {
42+ Tag, TargetCommitish, Title, Notes string
43+ Draft, Prerelease, MakeLatest bool
44+}
45+type UpdateReleaseParams struct {
46+ Tag, TargetCommitish, Title, Notes *string
47+ Draft, Prerelease, MakeLatest *bool
48+}
49+
50+// ReleaseRef selects exactly one of ID, Tag, Latest.
51+type ReleaseRef struct {
52+ ID int64
53+ Tag string
54+ Latest bool
55+}
56+
57+// AssetRef selects ID, (Tag, Name), or (Latest, Name).
58+type AssetRef struct {
59+ ID int64
60+ Tag, Name string
61+ Latest bool
62+}
63+type UploadAssetParams struct {
64+ ReleaseID int64
65+ Name, Label, ContentType string
66+ Size int64
67+ Replace bool
68+}
69+type UpdateAssetParams struct{ Name, Label *string }
70+type ReleaseUsage struct {
71+ Used int64
72+ Limits ReleaseLimits
73+}
74+type AssetChunk struct {
75+ Asset *domain.ReleaseAsset
76+ Data []byte
77+}
78+type ReleasePage struct {
79+ Releases []domain.Release
80+ NextPageToken string
81+}
82+
83+// ReleaseService manages releases and streams assets; *usecase.Releases implements it.
84+type ReleaseService interface {
85+ CreateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, params CreateReleaseParams) (domain.Release, error)
86+ GetRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ReleaseRef) (domain.Release, error)
87+ ListReleases(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string) (ReleasePage, error)
88+ UpdateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params UpdateReleaseParams) (domain.Release, error)
89+ DeleteRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error
90+ ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error)
91+ UploadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, params UploadAssetParams, body io.Reader) (domain.ReleaseAsset, error)
92+ UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params UpdateAssetParams) (domain.ReleaseAsset, error)
93+ DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64) error
94+ GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref AssetRef) (domain.ReleaseAsset, error)
95+ // DownloadAsset sends metadata first, then chunks at most domain.CIArtifactChunk;
96+ // length less than zero reads to the end.
97+ DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref AssetRef, offset, length int64, send func(AssetChunk) error) error
98+ Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ReleaseUsage, error)
99+}
100+
101+// ReleaseTagWriter creates lightweight tags without moving existing refs.
102+// *gitcli.Git implements it. An existing ref returns domain.ErrAlreadyExists.
103+type ReleaseTagWriter interface {
104+ CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error
105+}
106+
107+// ReleaseTagTrigger handles tag deletion; *usecase.Releases implements it.
108+type ReleaseTagTrigger interface {
109+ OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
110+}
111+
112+// ReleaseSweeper removes stale assets and blobs; *usecase.Releases implements it.
113+type ReleaseSweeper interface{ Sweep(ctx context.Context) }
114+
115+// ReleaseRepoCleaner removes contents after metadata deletion; *usecase.Releases implements it.
116+type ReleaseRepoCleaner interface {
117+ AssetsOf(ctx context.Context, repo domain.RepoName) ([]domain.ReleaseAsset, error)
118+ OnRepoDeleted(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset)
119+}
M internal/ports/services.go +17 -0
@@ -390,3 +390,20 @@ type EnvironmentService interface {
390390 ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error)
391391 DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error
392392 }
393+
394+// GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow
395+// GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it.
396+type GitHubReleaseAPI interface {
397+ CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error)
398+ GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error)
399+ ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error)
400+ UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error)
401+ DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error
402+ ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error)
403+ UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error)
404+ GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error)
405+ UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error)
406+ DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error
407+ // AssetByID resolves metadata for a download through ReleaseService.
408+ AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error)
409+}
M internal/usecase/ci.go +15 -7
@@ -125,14 +125,17 @@ type CIConfig struct {
125125 PublicURL string
126126 // Retention is how long runs, jobs, checks and logs are kept.
127127 Retention time.Duration
128- // Artifacts and ArtifactFiles keep the artifacts of the runs (CI-4 D3):
129- // the records and the contents. Without both the artifact services refuse
128+ // Artifacts and Blobs keep the records and contents of the artifacts.
129+ // Without both the artifact services refuse
130130 // every call. ArtifactLimits are their settings; a zero field takes its
131131 // default (the run retention of domain.CIRetentionDays,
132132 // domain.DefaultCIArtifactQuota, domain.DefaultCIArtifactMaxSize). Leave
133133 // them unset rather than assigning typed nil pointers.
134- Artifacts ports.CIArtifactStore
134+ Artifacts ports.CIArtifactStore
135+ // ArtifactFiles is the legacy reader; nil when $ERMITE_DATA_DIR/ci/artifacts
136+ // does not exist at startup; P8 wires it.
135137 ArtifactFiles ports.CIArtifactFiles
138+ Blobs ports.BlobStore
136139 ArtifactLimits ports.ArtifactLimits
137140
138141 // The durations below default to the contract's; tests shorten them.
@@ -186,6 +189,8 @@ type CIConfig struct {
186189 // ScheduleVisit, when set, is called with each repository the scheduler
187190 // looks at. Tests use it.
188191 ScheduleVisit func(repo domain.RepoName)
192+ // ReleaseSweeper collects abandoned release uploads during the daily sweep.
193+ ReleaseSweeper ports.ReleaseSweeper
189194 }
190195
191196 func (c CIConfig) withDefaults() CIConfig {
@@ -244,9 +249,12 @@ func (c CIConfig) withDefaults() CIConfig {
244249 // calls (a runner's update and an owner's cancel) never lose one another's
245250 // write. Assignment takes the assignment lock before the lock of the run.
246251 type CI struct {
247- cfg CIConfig
248- boot time.Time
249- queue chan ciTrigger
252+ cfg CIConfig
253+ // uploadSlots bounds the artifact uploads and assemblies buffering a
254+ // block in memory at once; nil (hand-built CI in tests) means unbounded.
255+ uploadSlots chan struct{}
256+ boot time.Time
257+ queue chan ciTrigger
250258 // runEvents are the requested and completed events of runs, for the
251259 // workflow_run workflows (ci_workflowrun.go).
252260 runEvents chan runEvent
@@ -315,7 +323,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
315323 cfg = cfg.withDefaults()
316324 run, stop := context.WithCancel(context.WithoutCancel(ctx))
317325 return &CI{
318- pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
326+ pushes: map[domain.RepoName]int{}, uploadSlots: make(chan struct{}, ciArtifactUploadSlots), pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
319327 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
320328 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
321329 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
M internal/usecase/ci_artifacts.go +325 -12
@@ -13,6 +13,7 @@ import (
1313 "fmt"
1414 "io"
1515 "strconv"
16+ "strings"
1617 "sync"
1718 "time"
1819
@@ -25,7 +26,7 @@ import (
2526 // The artifacts of the runs (artifacts v4, CI-4 D3). The runner translates the
2627 // toolkit's protocol and calls JobArtifactService with the job token; owners
2728 // use ArtifactService. The records are in CIConfig.Artifacts, the contents in
28-// CIConfig.ArtifactFiles, named by ids only.
29+// CIConfig.Blobs (ArtifactFiles for legacy records), named by ids only.
2930 //
3031 // Every write of the artifacts of a repository goes through its usage record
3132 // (ports.CIArtifactWrite): a decision is made on the records read after it and
@@ -50,6 +51,44 @@ const (
5051 commitTries = 5
5152 )
5253
54+// ciArtifactUploadSlots bounds the artifact block uploads and assemblies that
55+// hold an 8 MiB buffer at the same time, server-wide.
56+const ciArtifactUploadSlots = 16
57+
58+// blockBufs pools the 8 MiB buffers of uploads and assemblies. CIArtifactMaxBlockSize
59+// and ports.BlobPartSize are both 8 MiB.
60+var blockBufs = sync.Pool{New: func() any {
61+ b := make([]byte, max(domain.CIArtifactMaxBlockSize, ports.BlobPartSize))
62+ return &b
63+}}
64+
65+// acquireUploadSlot takes an upload slot without waiting; release frees it.
66+func (c *CI) acquireUploadSlot() (release func(), err error) {
67+ if c.uploadSlots == nil {
68+ return func() {}, nil
69+ }
70+ select {
71+ case c.uploadSlots <- struct{}{}:
72+ return func() { <-c.uploadSlots }, nil
73+ default:
74+ return nil, fmt.Errorf("%w: too many artifact uploads in progress, retry", domain.ErrResourceExhausted)
75+ }
76+}
77+
78+// waitUploadSlot takes an upload slot, waiting for one until ctx ends: a
79+// commit must not fail because block uploads are in flight.
80+func (c *CI) waitUploadSlot(ctx context.Context) (release func(), err error) {
81+ if c.uploadSlots == nil {
82+ return func() {}, nil
83+ }
84+ select {
85+ case c.uploadSlots <- struct{}{}:
86+ return func() { <-c.uploadSlots }, nil
87+ case <-ctx.Done():
88+ return nil, ctx.Err()
89+ }
90+}
91+
5392 var (
5493 // errNoArtifactWrite ends updateArtifacts without a write.
5594 errNoArtifactWrite = errors.New("no write")
@@ -57,7 +96,7 @@ var (
5796 errArtifactChanged = errors.New("artifact changed")
5897 )
5998
60-func (c *CI) artifactsOn() bool { return c.cfg.Artifacts != nil && c.cfg.ArtifactFiles != nil }
99+func (c *CI) artifactsOn() bool { return c.cfg.Artifacts != nil && c.cfg.Blobs != nil }
61100
62101 func artifactRef(a domain.CIArtifact) ports.CIArtifactRef {
63102 return ports.CIArtifactRef{RunID: a.RunID, ID: a.ID}
@@ -309,7 +348,13 @@ func (c *CI) removeArtifacts(ctx context.Context, repo domain.RepoName, runID in
309348 // logged: the sweep removes the files that no record names.
310349 func (c *CI) removeArtifactFiles(ctx context.Context, arts []domain.CIArtifact) {
311350 for _, a := range arts {
312- if err := c.cfg.ArtifactFiles.Remove(ctx, artifactRef(a)); err != nil {
351+ var err error
352+ if a.Store == domain.CIArtifactStoreBlob {
353+ err = errors.Join(c.cfg.Blobs.Delete(ctx, a.BlobKey()), c.removeBlobBlocks(ctx, a))
354+ } else if c.cfg.ArtifactFiles != nil {
355+ err = c.cfg.ArtifactFiles.Remove(ctx, artifactRef(a))
356+ }
357+ if err != nil {
313358 ctxLog(ctx).Warn().Err(err).Int64("artifact_id", a.ID).Msg("ci: remove the files of an artifact; the sweep will")
314359 }
315360 }
@@ -373,7 +418,7 @@ func (c *CI) CreateJobArtifact(ctx context.Context, secret string, params ports.
373418 }
374419 a := domain.CIArtifact{
375420 ID: id, Repo: j.Repo, RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID, Assignment: j.TokenHash,
376- Name: params.Name, MediaType: mediaType, State: domain.CIArtifactPending,
421+ Name: params.Name, MediaType: mediaType, State: domain.CIArtifactPending, Store: domain.CIArtifactStoreBlob,
377422 CreatedAt: now, ExpiresAt: domain.ArtifactExpiry(params.ExpiresAt, now, c.cfg.ArtifactLimits.Retention),
378423 }
379424 var discarded []domain.CIArtifact
@@ -460,15 +505,24 @@ func (c *CI) UploadJobArtifactBlock(ctx context.Context, secret, jobID string, a
460505 if a.State != domain.CIArtifactPending {
461506 return 0, errCommitted(a.ID)
462507 }
508+ // The slot is taken before anything is reserved, read or allocated.
509+ freeSlot, err := c.acquireUploadSlot()
510+ if err != nil {
511+ return 0, err
512+ }
513+ defer freeSlot()
463514 // Every byte is reserved before it is written, against the charges and
464515 // the other writes in flight (a block it replaces counts until the record
465516 // drops it): nothing beyond the limits ever reaches the disk.
466517 src := &reservingReader{ctx: ctx, c: c, a: a, r: data}
467518 defer func() { c.release(a, src.reserved) }()
468519 limits := c.cfg.ArtifactLimits
469- ref, file := artifactRef(a), newBlockFile(blockID)
470- size, err := c.cfg.ArtifactFiles.PutBlock(ctx, ref, file, src)
520+ file := newBlockFile(blockID)
521+ size, err := c.putUnsized(ctx, a.BlockKey(file), src)
471522 if err != nil {
523+ if derr := c.cfg.Blobs.Delete(context.WithoutCancel(ctx), a.BlockKey(file)); derr != nil {
524+ ctxLog(ctx).Warn().Err(derr).Msg("ci: remove a refused block; the sweep will")
525+ }
472526 return 0, err
473527 }
474528 var old *ports.CIArtifactBlock
@@ -509,13 +563,13 @@ func (c *CI) UploadJobArtifactBlock(ctx context.Context, secret, jobID string, a
509563 return nil
510564 })
511565 if err != nil {
512- if rerr := c.cfg.ArtifactFiles.RemoveBlock(ctx, ref, file); rerr != nil {
566+ if rerr := c.cfg.Blobs.Delete(context.WithoutCancel(ctx), a.BlockKey(file)); rerr != nil {
513567 ctxLog(ctx).Warn().Err(rerr).Int64("artifact_id", a.ID).Msg("ci: remove a refused block; the sweep will")
514568 }
515569 return 0, err
516570 }
517571 if old != nil {
518- if err := c.cfg.ArtifactFiles.RemoveBlock(ctx, ref, old.File); err != nil {
572+ if err := c.cfg.Blobs.Delete(ctx, a.BlockKey(old.File)); err != nil {
519573 ctxLog(ctx).Warn().Err(err).Int64("artifact_id", a.ID).Msg("ci: remove a replaced block")
520574 }
521575 }
@@ -602,7 +656,7 @@ func (c *CI) CommitJobArtifact(ctx context.Context, secret, jobID string, artifa
602656 return 0, "", err
603657 }
604658 }
605- size, digest, err := c.cfg.ArtifactFiles.Assemble(ctx, artifactRef(a), files)
659+ size, digest, err := c.assembleBlobs(ctx, a, files, total)
606660 if err != nil {
607661 c.release(a, excess)
608662 }
@@ -638,7 +692,7 @@ func (c *CI) CommitJobArtifact(ctx context.Context, secret, jobID string, artifa
638692 if err != nil {
639693 return 0, "", err
640694 }
641- if err := c.cfg.ArtifactFiles.RemoveBlocks(ctx, artifactRef(a)); err != nil {
695+ if err := c.removeBlobBlocks(ctx, a); err != nil {
642696 ctxLog(ctx).Warn().Err(err).Int64("artifact_id", a.ID).Msg("ci: remove the blocks of a committed artifact")
643697 }
644698 c.dropIfEnded(ctx, j)
@@ -782,7 +836,23 @@ func (c *CI) streamArtifact(ctx context.Context, a domain.CIArtifact, offset int
782836 if offset < 0 || offset > a.Size {
783837 return fmt.Errorf("%w: offset %d is beyond the size %d", domain.ErrOutOfRange, offset, a.Size)
784838 }
785- f, err := c.cfg.ArtifactFiles.Open(ctx, artifactRef(a), offset)
839+ var f io.ReadCloser
840+ var err error
841+ if a.Store == domain.CIArtifactStoreBlob {
842+ // BlobStore rejects offsets at EOF, while artifact downloads permit
843+ // an empty resumed stream (including zero-byte artifacts).
844+ if offset == a.Size {
845+ _, err = c.cfg.Blobs.Stat(ctx, a.BlobKey())
846+ f = io.NopCloser(bytes.NewReader(nil))
847+ } else {
848+ f, _, err = c.cfg.Blobs.Open(ctx, a.BlobKey(), offset, -1)
849+ }
850+ } else if c.cfg.ArtifactFiles != nil {
851+ f, err = c.cfg.ArtifactFiles.Open(ctx, artifactRef(a), offset)
852+ } else {
853+ ctxLog(ctx).Error().Int64("artifact_id", a.ID).Msg("ci: legacy artifact reader is unavailable")
854+ return domain.ErrNotFound
855+ }
786856 if err != nil {
787857 return err
788858 }
@@ -1008,6 +1078,10 @@ func (c *CI) sweepArtifactOrphans(ctx context.Context) {
10081078 }
10091079 }
10101080 }
1081+ c.sweepArtifactBlobOrphans(ctx)
1082+ if c.cfg.ArtifactFiles == nil {
1083+ return
1084+ }
10111085 c.removeOrphans(ctx, "artifacts", "artifact directories", c.cfg.ArtifactFiles.RunIDs, c.cfg.ArtifactFiles.RemoveRuns)
10121086 runIDs, err := c.cfg.ArtifactFiles.RunIDs(ctx)
10131087 if err != nil {
@@ -1022,7 +1096,7 @@ func (c *CI) sweepArtifactOrphans(ctx context.Context) {
10221096 }
10231097 for _, id := range ids {
10241098 a, err := c.cfg.Artifacts.GetArtifact(ctx, id)
1025- if err == nil && a.RunID == run {
1099+ if err == nil && a.RunID == run && a.Store != domain.CIArtifactStoreBlob {
10261100 continue
10271101 }
10281102 if err != nil && !errors.Is(err, domain.ErrNotFound) {
@@ -1035,3 +1109,242 @@ func (c *CI) sweepArtifactOrphans(ctx context.Context) {
10351109 }
10361110 }
10371111 }
1112+
1113+// putUnsized buffers at most one toolkit block; Put requires an exact size.
1114+func (c *CI) putUnsized(ctx context.Context, key string, r io.Reader) (int64, error) {
1115+ // A fixed backing array prevents bytes.Buffer's geometric growth from
1116+ // allocating beyond the block bound. Probe the next byte separately.
1117+ bp := blockBufs.Get().(*[]byte)
1118+ defer blockBufs.Put(bp)
1119+ buf := (*bp)[:domain.CIArtifactMaxBlockSize]
1120+ n, err := io.ReadFull(r, buf)
1121+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
1122+ return 0, err
1123+ }
1124+ if n == len(buf) {
1125+ var extra [1]byte
1126+ m, err := io.ReadFull(r, extra[:])
1127+ if m != 0 {
1128+ return 0, fmt.Errorf("%w: a block has at most 8 MiB", domain.ErrInvalid)
1129+ }
1130+ if err != io.EOF {
1131+ return 0, err
1132+ }
1133+ }
1134+ if err := ctx.Err(); err != nil {
1135+ return 0, err
1136+ }
1137+ _, err = c.cfg.Blobs.Put(ctx, key, bytes.NewReader(buf[:n]), int64(n))
1138+ return int64(n), err
1139+}
1140+
1141+func (c *CI) removeBlobBlocks(ctx context.Context, a domain.CIArtifact) error {
1142+ return c.cfg.Blobs.List(ctx, a.BlobKey()+".blocks/", func(b ports.BlobInfo) error {
1143+ return c.cfg.Blobs.Delete(ctx, b.Key)
1144+ })
1145+}
1146+
1147+// assembleBlobs keeps one 8 MiB part in memory, even when a block crosses
1148+// part boundaries or is repeated in the commit list.
1149+func (c *CI) assembleBlobs(ctx context.Context, a domain.CIArtifact, files []string, total int64) (int64, string, error) {
1150+ freeSlot, err := c.waitUploadSlot(ctx)
1151+ if err != nil {
1152+ return 0, "", err
1153+ }
1154+ defer freeSlot()
1155+ var upload ports.MultipartUpload
1156+ if total > ports.BlobPartSize {
1157+ var err error
1158+ upload, err = c.cfg.Blobs.NewMultipart(ctx, a.BlobKey())
1159+ if err != nil {
1160+ return 0, "", err
1161+ }
1162+ }
1163+ completed := false
1164+ defer func() {
1165+ if upload != nil && !completed {
1166+ if err := upload.Abort(context.WithoutCancel(ctx)); err != nil {
1167+ ctxLog(ctx).Warn().Err(err).Msg("ci: abort artifact assembly; the sweep will")
1168+ }
1169+ }
1170+ }()
1171+ bp := blockBufs.Get().(*[]byte)
1172+ defer blockBufs.Put(bp)
1173+ buf := (*bp)[:ports.BlobPartSize]
1174+ used, part := 0, 1
1175+ var size int64
1176+ h := sha256.New()
1177+ flush := func() error {
1178+ if err := upload.UploadPart(ctx, part, bytes.NewReader(buf[:used]), int64(used)); err != nil {
1179+ return err
1180+ }
1181+ used = 0
1182+ part++
1183+ return nil
1184+ }
1185+ for _, file := range files {
1186+ r, info, err := c.cfg.Blobs.Open(ctx, a.BlockKey(file), 0, -1)
1187+ // Empty blocks cannot be opened under BlobStore's EOF contract.
1188+ if errors.Is(err, domain.ErrOutOfRange) {
1189+ info, err = c.cfg.Blobs.Stat(ctx, a.BlockKey(file))
1190+ if err == nil && info.Size == 0 {
1191+ continue
1192+ }
1193+ if err == nil {
1194+ return 0, "", domain.ErrOutOfRange
1195+ }
1196+ }
1197+ if err != nil {
1198+ return 0, "", err
1199+ }
1200+ if info.Size > total-size {
1201+ _ = r.Close()
1202+ return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid)
1203+ }
1204+ left := info.Size
1205+ for left > 0 {
1206+ if err := ctx.Err(); err != nil {
1207+ _ = r.Close()
1208+ return 0, "", err
1209+ }
1210+ want := min(left, int64(len(buf)-used))
1211+ if want == 0 {
1212+ _ = r.Close()
1213+ return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid)
1214+ }
1215+ n, err := io.ReadFull(r, buf[used:used+int(want)])
1216+ if err != nil {
1217+ _ = r.Close()
1218+ return 0, "", err
1219+ }
1220+ h.Write(buf[used : used+n])
1221+ used += n
1222+ size += int64(n)
1223+ left -= int64(n)
1224+ if used == len(buf) && upload != nil {
1225+ if err := flush(); err != nil {
1226+ _ = r.Close()
1227+ return 0, "", err
1228+ }
1229+ }
1230+ }
1231+ var extra [1]byte
1232+ n, err := io.ReadFull(r, extra[:])
1233+ cerr := r.Close()
1234+ if n != 0 || err != io.EOF {
1235+ return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid)
1236+ }
1237+ if cerr != nil {
1238+ return 0, "", cerr
1239+ }
1240+ }
1241+ if size != total {
1242+ return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid)
1243+ }
1244+ if upload == nil {
1245+ if _, err := c.cfg.Blobs.Put(ctx, a.BlobKey(), bytes.NewReader(buf[:used]), size); err != nil {
1246+ return 0, "", err
1247+ }
1248+ } else {
1249+ if used > 0 {
1250+ if err := flush(); err != nil {
1251+ return 0, "", err
1252+ }
1253+ }
1254+ if _, err := upload.Complete(ctx); err != nil {
1255+ return 0, "", err
1256+ }
1257+ completed = true
1258+ }
1259+ return size, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
1260+}
1261+
1262+func (c *CI) sweepArtifactBlobOrphans(ctx context.Context) {
1263+ log := ctxLog(ctx)
1264+ cutoff := c.now().Add(-domain.CIArtifactPendingMax)
1265+ err := c.cfg.Blobs.List(ctx, "ci/", func(b ports.BlobInfo) error {
1266+ if !b.ModTime.Before(cutoff) {
1267+ return nil
1268+ }
1269+ run, id, block, ok := artifactBlobIDs(b.Key)
1270+ if !ok {
1271+ return nil
1272+ }
1273+ a, err := c.cfg.Artifacts.GetArtifact(ctx, id)
1274+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
1275+ return nil
1276+ }
1277+ if err == nil && a.RunID == run && a.Store == domain.CIArtifactStoreBlob && (!block || a.State == domain.CIArtifactPending) {
1278+ return nil
1279+ }
1280+ if err := c.cfg.Blobs.Delete(ctx, b.Key); err != nil {
1281+ log.Error().Err(err).Msg("ci: remove orphan artifact blob")
1282+ }
1283+ return nil
1284+ })
1285+ if err != nil {
1286+ log.Error().Err(err).Msg("ci: list artifact blobs")
1287+ }
1288+ err = c.cfg.Blobs.ListMultipart(ctx, "ci/", func(m ports.MultipartInfo) error {
1289+ if m.Started.Before(cutoff) {
1290+ if err := c.cfg.Blobs.AbortMultipart(ctx, m.Key, m.ID); err != nil {
1291+ log.Error().Err(err).Msg("ci: abort abandoned artifact upload")
1292+ }
1293+ }
1294+ return nil
1295+ })
1296+ if err != nil {
1297+ log.Error().Err(err).Msg("ci: list artifact multipart uploads")
1298+ }
1299+}
1300+
1301+func artifactBlobIDs(key string) (run, id int64, block, ok bool) {
1302+ rest, found := strings.CutPrefix(key, "ci/")
1303+ if !found {
1304+ return
1305+ }
1306+ r, tail, found := strings.Cut(rest, "/")
1307+ if !found || len(r) != 20 {
1308+ return
1309+ }
1310+ name, file, block := strings.Cut(tail, ".blocks/")
1311+ if len(name) != 20 || (block && file == "") {
1312+ return 0, 0, false, false
1313+ }
1314+ run, err := strconv.ParseInt(r, 10, 64)
1315+ if err != nil || run <= 0 {
1316+ return 0, 0, false, false
1317+ }
1318+ id, err = strconv.ParseInt(name, 10, 64)
1319+ return run, id, block, err == nil && id > 0
1320+}
1321+
1322+// removeArtifactRuns follows repository deletion, when records are already
1323+// gone. Run ids are global, so their blob prefixes cannot name another repo.
1324+func (c *CI) removeArtifactRuns(ctx context.Context, runIDs []int64) error {
1325+ var errs []error
1326+ if c.cfg.ArtifactFiles != nil {
1327+ errs = append(errs, c.cfg.ArtifactFiles.RemoveRuns(ctx, runIDs))
1328+ }
1329+ if c.cfg.Blobs != nil {
1330+ for _, run := range runIDs {
1331+ if run <= 0 {
1332+ continue
1333+ }
1334+ prefix := fmt.Sprintf("ci/%020d/", run)
1335+ errs = append(errs, c.cfg.Blobs.List(ctx, prefix, func(b ports.BlobInfo) error {
1336+ if err := c.cfg.Blobs.Delete(ctx, b.Key); err != nil {
1337+ errs = append(errs, err)
1338+ }
1339+ return nil
1340+ }))
1341+ errs = append(errs, c.cfg.Blobs.ListMultipart(ctx, prefix, func(m ports.MultipartInfo) error {
1342+ if err := c.cfg.Blobs.AbortMultipart(ctx, m.Key, m.ID); err != nil {
1343+ errs = append(errs, err)
1344+ }
1345+ return nil
1346+ }))
1347+ }
1348+ }
1349+ return errors.Join(errs...)
1350+}
A internal/usecase/ci_artifacts_blob_test.go +301 -0
@@ -0,0 +1,301 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "errors"
11+ "io"
12+ "strconv"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+)
25+
26+// artifactBlobMock uses generated port mocks with byte storage in callbacks.
27+// Real-disk end-to-end behavior is exercised by internal/app's artifact rig.
28+func artifactBlobMock(t *testing.T, failPart bool) (*mocks.BlobStore, *int, *int) {
29+ t.Helper()
30+ b := mocks.NewBlobStore(t)
31+ objects := map[string][]byte{}
32+ active := map[string]ports.MultipartInfo{}
33+ puts, multiparts := new(int), new(int)
34+ b.EXPECT().Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
35+ *puts++
36+ data, err := io.ReadAll(r)
37+ if err != nil {
38+ return ports.BlobInfo{}, err
39+ }
40+ if int64(len(data)) != size {
41+ return ports.BlobInfo{}, domain.ErrInvalid
42+ }
43+ objects[key] = data
44+ return ports.BlobInfo{Key: key, Size: size}, nil
45+ }).Maybe()
46+ b.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
47+ data, ok := objects[key]
48+ if !ok {
49+ return nil, ports.BlobInfo{}, domain.ErrNotFound
50+ }
51+ info := ports.BlobInfo{Key: key, Size: int64(len(data))}
52+ if offset >= info.Size {
53+ return nil, info, domain.ErrOutOfRange
54+ }
55+ end := info.Size
56+ if length >= 0 {
57+ end = min(end, offset+length)
58+ }
59+ return io.NopCloser(bytes.NewReader(data[offset:end])), info, nil
60+ }).Maybe()
61+ b.EXPECT().Stat(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string) (ports.BlobInfo, error) {
62+ data, ok := objects[key]
63+ if !ok {
64+ return ports.BlobInfo{}, domain.ErrNotFound
65+ }
66+ return ports.BlobInfo{Key: key, Size: int64(len(data))}, nil
67+ }).Maybe()
68+ b.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string) error { delete(objects, key); return nil }).Maybe()
69+ b.EXPECT().List(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, prefix string, fn func(ports.BlobInfo) error) error {
70+ for key, data := range objects {
71+ if strings.HasPrefix(key, prefix) {
72+ if err := fn(ports.BlobInfo{Key: key, Size: int64(len(data))}); err != nil {
73+ return err
74+ }
75+ }
76+ }
77+ return nil
78+ }).Maybe()
79+ b.EXPECT().ListMultipart(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
80+ for key, m := range active {
81+ if strings.HasPrefix(key, prefix) {
82+ if err := fn(m); err != nil {
83+ return err
84+ }
85+ }
86+ }
87+ return nil
88+ }).Maybe()
89+ b.EXPECT().AbortMultipart(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key, id string) error { delete(active, key); return nil }).Maybe()
90+ b.EXPECT().NewMultipart(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string) (ports.MultipartUpload, error) {
91+ *multiparts++
92+ active[key] = ports.MultipartInfo{Key: key, ID: "upload"}
93+ u := mocks.NewMultipartUpload(t)
94+ var data []byte
95+ u.EXPECT().UploadPart(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ int, r io.Reader, size int64) error {
96+ if failPart {
97+ return errors.New("part failed")
98+ }
99+ part, err := io.ReadAll(r)
100+ data = append(data, part...)
101+ return err
102+ }).Maybe()
103+ u.EXPECT().Complete(mock.Anything).RunAndReturn(func(context.Context) (ports.BlobInfo, error) {
104+ objects[key] = data
105+ delete(active, key)
106+ return ports.BlobInfo{Key: key, Size: int64(len(data))}, nil
107+ }).Maybe()
108+ u.EXPECT().Abort(mock.Anything).RunAndReturn(func(context.Context) error { delete(active, key); return nil }).Maybe()
109+ return u, nil
110+ }).Maybe()
111+ return b, puts, multiparts
112+}
113+
114+func TestArtifactBlobAssembly(t *testing.T) {
115+ for _, size := range []int{0, 1, ports.BlobPartSize, ports.BlobPartSize + 1, 2*ports.BlobPartSize + 17} {
116+ t.Run(strconv.Itoa(size), func(t *testing.T) {
117+ ctx := t.Context()
118+ b, puts, multiparts := artifactBlobMock(t, false)
119+ c := &CI{cfg: CIConfig{Blobs: b}}
120+ a := domain.CIArtifact{RunID: 1, ID: 2, Store: domain.CIArtifactStoreBlob}
121+ data := bytes.Repeat([]byte("x"), size)
122+ // Parts straddle block boundaries, with an empty block in the list.
123+ cut := min(size, 3<<20)
124+ _, err := b.Put(ctx, a.BlockKey("a"), bytes.NewReader(data[:cut]), int64(cut))
125+ require.NoError(t, err)
126+ _, err = b.Put(ctx, a.BlockKey("b"), bytes.NewReader(data[cut:]), int64(size-cut))
127+ require.NoError(t, err)
128+ _, err = b.Put(ctx, a.BlockKey("empty"), bytes.NewReader(nil), 0)
129+ require.NoError(t, err)
130+ *puts = 0
131+ n, digest, err := c.assembleBlobs(ctx, a, []string{"a", "empty", "b"}, int64(size))
132+ require.NoError(t, err)
133+ assert.EqualValues(t, size, n)
134+ sum := sha256.Sum256(data)
135+ assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), digest)
136+ if size <= ports.BlobPartSize {
137+ assert.Equal(t, 1, *puts)
138+ assert.Zero(t, *multiparts)
139+ } else {
140+ assert.Zero(t, *puts)
141+ assert.Equal(t, 1, *multiparts)
142+ }
143+ if size > 0 {
144+ r, _, err := b.Open(ctx, a.BlobKey(), 0, -1)
145+ require.NoError(t, err)
146+ got, err := io.ReadAll(r)
147+ require.NoError(t, err)
148+ require.NoError(t, r.Close())
149+ assert.Equal(t, data, got)
150+ } else {
151+ info, err := b.Stat(ctx, a.BlobKey())
152+ require.NoError(t, err)
153+ assert.Zero(t, info.Size)
154+ }
155+ // No legacy reader is needed for a blob download, including EOF resumes.
156+ a.Size = int64(size)
157+ var got []byte
158+ require.NoError(t, c.streamArtifact(ctx, a, 0, func(ch ports.ArtifactChunk) error { got = append(got, ch.Data...); return nil }))
159+ assert.Equal(t, data, append([]byte{}, got...))
160+ require.NoError(t, c.streamArtifact(ctx, a, a.Size, func(ports.ArtifactChunk) error { return nil }))
161+ c.removeArtifactFiles(ctx, []domain.CIArtifact{a})
162+ _, err = b.Stat(ctx, a.BlobKey())
163+ assert.ErrorIs(t, err, domain.ErrNotFound)
164+ require.NoError(t, b.List(ctx, a.BlobKey()+".blocks/", func(ports.BlobInfo) error { t.Error("block survived deletion"); return nil }))
165+ })
166+ }
167+}
168+
169+func TestArtifactBlobAssemblyFailureAborts(t *testing.T) {
170+ for _, failPart := range []bool{false, true} {
171+ t.Run(map[bool]string{false: "missing block", true: "failed part"}[failPart], func(t *testing.T) {
172+ b, _, multiparts := artifactBlobMock(t, failPart)
173+ c := &CI{cfg: CIConfig{Blobs: b}}
174+ a := domain.CIArtifact{RunID: 1, ID: 2}
175+ data := bytes.Repeat([]byte("a"), ports.BlobPartSize)
176+ _, err := b.Put(t.Context(), a.BlockKey("a"), bytes.NewReader(data), int64(len(data)))
177+ require.NoError(t, err)
178+ _, _, err = c.assembleBlobs(t.Context(), a, []string{"a", "missing"}, ports.BlobPartSize+1)
179+ require.Error(t, err)
180+ assert.Equal(t, 1, *multiparts)
181+ _, err = b.Stat(t.Context(), a.BlobKey())
182+ assert.ErrorIs(t, err, domain.ErrNotFound)
183+ require.NoError(t, b.ListMultipart(t.Context(), "ci/", func(ports.MultipartInfo) error { t.Error("multipart survived failure"); return nil }))
184+ })
185+ }
186+}
187+
188+func TestArtifactPutUnsizedBound(t *testing.T) {
189+ b, _, _ := artifactBlobMock(t, false)
190+ c := &CI{cfg: CIConfig{Blobs: b}}
191+ for _, size := range []int{0, 1, domain.CIArtifactMaxBlockSize, domain.CIArtifactMaxBlockSize + 1} {
192+ n, err := c.putUnsized(t.Context(), "ci/1/2", bytes.NewReader(make([]byte, size)))
193+ if size > domain.CIArtifactMaxBlockSize {
194+ assert.ErrorIs(t, err, domain.ErrInvalid)
195+ assert.Contains(t, err.Error(), "a block has at most 8 MiB")
196+ } else {
197+ require.NoError(t, err)
198+ assert.EqualValues(t, size, n)
199+ }
200+ }
201+ ctx, cancel := context.WithCancel(t.Context())
202+ cancel()
203+ _, err := c.putUnsized(ctx, "ci/1/cancelled", bytes.NewReader([]byte("x")))
204+ assert.ErrorIs(t, err, context.Canceled)
205+ _, err = b.Stat(t.Context(), "ci/1/cancelled")
206+ assert.ErrorIs(t, err, domain.ErrNotFound)
207+}
208+
209+func TestArtifactLegacyStream(t *testing.T) {
210+ a := domain.CIArtifact{RunID: 1, ID: 2, Size: 6}
211+ legacy := mocks.NewCIArtifactFiles(t)
212+ legacy.EXPECT().Open(mock.Anything, artifactRef(a), int64(2)).RunAndReturn(func(context.Context, ports.CIArtifactRef, int64) (io.ReadCloser, error) {
213+ return io.NopCloser(bytes.NewReader([]byte("gacy"))), nil
214+ }).Twice()
215+ c := &CI{cfg: CIConfig{ArtifactFiles: legacy}}
216+ for _, store := range []domain.CIArtifactStore{domain.CIArtifactStoreLegacy, "disk"} {
217+ a.Store = store
218+ var got []byte
219+ require.NoError(t, c.streamArtifact(t.Context(), a, 2, func(ch ports.ArtifactChunk) error { got = append(got, ch.Data...); return nil }))
220+ assert.Equal(t, "gacy", string(got))
221+ }
222+ c.cfg.ArtifactFiles = nil
223+ assert.ErrorIs(t, c.streamArtifact(t.Context(), a, 0, func(ports.ArtifactChunk) error { return nil }), domain.ErrNotFound)
224+}
225+
226+func TestArtifactBlobOrphanSweep(t *testing.T) {
227+ ctx := t.Context()
228+ b := mocks.NewBlobStore(t)
229+ s := mocks.NewCIArtifactStore(t)
230+ c := &CI{cfg: CIConfig{Blobs: b, Artifacts: s, Clock: stillClock{}}}
231+ now := c.now()
232+ old := now.Add(-25 * time.Hour)
233+ pending := domain.CIArtifact{RunID: 1, ID: 2, Store: domain.CIArtifactStoreBlob, State: domain.CIArtifactPending}
234+ committed := pending
235+ committed.ID = 3
236+ committed.State = domain.CIArtifactCommitted
237+ missing := pending
238+ missing.ID = 4
239+ b.EXPECT().List(mock.Anything, "ci/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.BlobInfo) error) error {
240+ for _, info := range []ports.BlobInfo{
241+ {Key: missing.BlobKey(), ModTime: now}, // young: no lookup, no deletion
242+ {Key: missing.BlobKey(), ModTime: old},
243+ {Key: pending.BlockKey("a"), ModTime: old},
244+ {Key: committed.BlockKey("a"), ModTime: old},
245+ {Key: committed.BlobKey(), ModTime: old},
246+ } {
247+ require.NoError(t, fn(info))
248+ }
249+ return nil
250+ })
251+ s.EXPECT().GetArtifact(mock.Anything, missing.ID).Return(domain.CIArtifact{}, domain.ErrNotFound).Once()
252+ s.EXPECT().GetArtifact(mock.Anything, pending.ID).Return(pending, nil).Once()
253+ s.EXPECT().GetArtifact(mock.Anything, committed.ID).Return(committed, nil).Twice()
254+ b.EXPECT().Delete(mock.Anything, missing.BlobKey()).Return(nil).Once()
255+ b.EXPECT().Delete(mock.Anything, committed.BlockKey("a")).Return(nil).Once()
256+ b.EXPECT().ListMultipart(mock.Anything, "ci/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.MultipartInfo) error) error {
257+ require.NoError(t, fn(ports.MultipartInfo{Key: missing.BlobKey(), ID: "old", Started: old}))
258+ require.NoError(t, fn(ports.MultipartInfo{Key: missing.BlobKey(), ID: "young", Started: now}))
259+ return nil
260+ })
261+ b.EXPECT().AbortMultipart(mock.Anything, missing.BlobKey(), "old").Return(nil).Once()
262+ c.sweepArtifactBlobOrphans(ctx)
263+}
264+
265+func TestArtifactRemoveRunsWithoutLegacyReader(t *testing.T) {
266+ ctx := t.Context()
267+ b, _, _ := artifactBlobMock(t, false)
268+ c := &CI{cfg: CIConfig{Blobs: b}}
269+ a := domain.CIArtifact{RunID: 1, ID: 2}
270+ other := a
271+ other.RunID = 2
272+ for _, key := range []string{a.BlobKey(), a.BlockKey("a"), other.BlobKey()} {
273+ _, err := b.Put(ctx, key, bytes.NewReader([]byte("x")), 1)
274+ require.NoError(t, err)
275+ }
276+ _, err := b.NewMultipart(ctx, a.BlobKey())
277+ require.NoError(t, err)
278+ require.NoError(t, c.removeArtifactRuns(ctx, []int64{1}))
279+ _, err = b.Stat(ctx, a.BlobKey())
280+ assert.ErrorIs(t, err, domain.ErrNotFound)
281+ _, err = b.Stat(ctx, a.BlockKey("a"))
282+ assert.ErrorIs(t, err, domain.ErrNotFound)
283+ _, err = b.Stat(ctx, other.BlobKey())
284+ require.NoError(t, err)
285+ require.NoError(t, b.ListMultipart(ctx, "ci/", func(ports.MultipartInfo) error { t.Error("upload survived run deletion"); return nil }))
286+}
287+
288+func TestArtifactBlobIDs(t *testing.T) {
289+ a := domain.CIArtifact{RunID: 7, ID: 42}
290+ for _, key := range []string{a.BlobKey(), a.BlockKey("abcdef.0123")} {
291+ run, id, block, ok := artifactBlobIDs(key)
292+ assert.True(t, ok)
293+ assert.EqualValues(t, 7, run)
294+ assert.EqualValues(t, 42, id)
295+ assert.Equal(t, strings.Contains(key, ".blocks/"), block)
296+ }
297+ for _, key := range []string{"release/1", "ci/1/2", a.BlobKey() + ".blocks/", a.BlobKey() + "/wrong"} {
298+ _, _, _, ok := artifactBlobIDs(key)
299+ assert.False(t, ok, key)
300+ }
301+}
A internal/usecase/ci_artifacts_slots_test.go +179 -0
@@ -0,0 +1,179 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "io"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+// gateReader signals its first Read on started, then blocks until release is
24+// closed or ctx ends; it then returns data (or err when set).
25+type gateReader struct {
26+ ctx context.Context
27+ started chan struct{}
28+ release chan struct{}
29+ data []byte
30+ err error
31+ once sync.Once
32+ reads int
33+ r *bytes.Reader
34+}
35+
36+func (g *gateReader) Read(p []byte) (int, error) {
37+ g.reads++
38+ g.once.Do(func() {
39+ if g.started != nil {
40+ close(g.started)
41+ }
42+ })
43+ if g.release != nil {
44+ select {
45+ case <-g.release:
46+ case <-g.ctx.Done():
47+ return 0, g.ctx.Err()
48+ }
49+ }
50+ if g.err != nil {
51+ return 0, g.err
52+ }
53+ if g.r == nil {
54+ g.r = bytes.NewReader(g.data)
55+ }
56+ return g.r.Read(p)
57+}
58+
59+type slotFixture struct {
60+ c *CI
61+ secret string
62+ job domain.CIJob
63+ puts *int
64+}
65+
66+func newSlotFixture(t *testing.T, slots int) *slotFixture {
67+ t.Helper()
68+ repo, err := domain.ParseRepoName("proj")
69+ require.NoError(t, err)
70+ secret := domain.JobTokenPrefix + strings.Repeat("a", domain.CITokenRandomLen)
71+ hash := domain.HashTokenSecret(secret)
72+ j := domain.CIJob{ID: "1-0-0", RunID: 1, RunAttempt: 1, Repo: repo, TokenHash: hash, Phase: domain.CIPhaseRunning}
73+ a := domain.CIArtifact{ID: 7, Repo: repo, RunID: 1, Attempt: 1, JobID: j.ID, Assignment: hash, State: domain.CIArtifactPending}
74+
75+ store := mocks.NewCIStore(t)
76+ store.EXPECT().FindJobByToken(mock.Anything, hash, mock.Anything).Return(j, nil).Maybe()
77+ store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil).Maybe()
78+ arts := mocks.NewCIArtifactStore(t)
79+ arts.EXPECT().ArtifactUsage(mock.Anything, repo).Return(0, 1, nil).Maybe()
80+ arts.EXPECT().GetArtifact(mock.Anything, a.ID).Return(a, nil).Maybe()
81+ arts.EXPECT().ArtifactBlocks(mock.Anything, a.ID).Return(nil, nil).Maybe()
82+ arts.EXPECT().ApplyArtifacts(mock.Anything, mock.Anything).Return(nil).Maybe()
83+ clock := mocks.NewClock(t)
84+ clock.EXPECT().Now().Return(time.Unix(1000, 0)).Maybe()
85+ blobs, puts, _ := artifactBlobMock(t, false)
86+ c := &CI{
87+ cfg: CIConfig{
88+ Store: store, Artifacts: arts, Blobs: blobs, Clock: clock,
89+ ArtifactLimits: ports.ArtifactLimits{Quota: 1 << 30, MaxSize: 1 << 30},
90+ },
91+ uploadSlots: make(chan struct{}, slots),
92+ }
93+ return &slotFixture{c: c, secret: secret, job: j, puts: puts}
94+}
95+
96+func (f *slotFixture) upload(ctx context.Context, r io.Reader) (int64, error) {
97+ return f.c.UploadJobArtifactBlock(ctx, f.secret, f.job.ID, 7, []byte("blk"), r)
98+}
99+
100+func TestArtifactUploadSlotsBound(t *testing.T) {
101+ for _, mode := range []string{"success", "body error", "ctx cancel"} {
102+ t.Run(mode, func(t *testing.T) {
103+ f := newSlotFixture(t, 1)
104+ ctx, cancel := context.WithCancel(t.Context())
105+ defer cancel()
106+ hold := &gateReader{ctx: ctx, started: make(chan struct{}), release: make(chan struct{}), data: []byte("held")}
107+ if mode == "body error" {
108+ hold.err = errors.New("connection reset")
109+ }
110+ type result struct {
111+ n int64
112+ err error
113+ }
114+ done := make(chan result, 1)
115+ go func() { n, err := f.upload(ctx, hold); done <- result{n, err} }()
116+ <-hold.started
117+
118+ // The slot is taken: the next upload is refused at once, before
119+ // reading its body, and nothing is reserved or stored.
120+ refused := &gateReader{ctx: t.Context(), data: []byte("x")}
121+ _, err := f.upload(t.Context(), refused)
122+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
123+ require.Zero(t, refused.reads)
124+ require.Zero(t, *f.puts)
125+ require.Zero(t, f.c.reservations.of(f.job.Repo).total)
126+
127+ switch mode {
128+ case "ctx cancel":
129+ cancel()
130+ default:
131+ close(hold.release)
132+ }
133+ res := <-done
134+ if mode == "success" {
135+ require.NoError(t, res.err)
136+ require.EqualValues(t, 4, res.n)
137+ } else {
138+ require.Error(t, res.err)
139+ }
140+ require.Empty(t, f.c.uploadSlots, "the slot is released")
141+
142+ n, err := f.upload(t.Context(), &gateReader{ctx: t.Context(), data: []byte("again")})
143+ require.NoError(t, err)
144+ require.EqualValues(t, 5, n)
145+ require.Empty(t, f.c.uploadSlots)
146+ })
147+ }
148+}
149+
150+func TestArtifactAssemblyTakesASlot(t *testing.T) {
151+ b, _, _ := artifactBlobMock(t, false)
152+ c := &CI{cfg: CIConfig{Blobs: b}, uploadSlots: make(chan struct{}, 1)}
153+ a := domain.CIArtifact{RunID: 1, ID: 2}
154+ _, err := b.Put(t.Context(), a.BlockKey("a"), strings.NewReader("hi"), 2)
155+ require.NoError(t, err)
156+
157+ // A full set of slots makes the assembly wait, not fail; it gives up
158+ // only when its context ends.
159+ c.uploadSlots <- struct{}{}
160+ ctx, cancel := context.WithCancel(t.Context())
161+ cancel()
162+ _, _, err = c.assembleBlobs(ctx, a, []string{"a"}, 2)
163+ require.ErrorIs(t, err, context.Canceled)
164+
165+ type result struct {
166+ n int64
167+ err error
168+ }
169+ done := make(chan result, 1)
170+ go func() {
171+ n, _, err := c.assembleBlobs(t.Context(), a, []string{"a"}, 2)
172+ done <- result{n, err}
173+ }()
174+ <-c.uploadSlots // free the slot: the waiting assembly proceeds
175+ res := <-done
176+ require.NoError(t, res.err)
177+ require.EqualValues(t, 2, res.n)
178+ require.Empty(t, c.uploadSlots)
179+}
M internal/usecase/ci_watch.go +4 -3
@@ -276,6 +276,9 @@ func (c *CI) sweep(ctx context.Context) {
276276 c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns)
277277 c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns)
278278 c.sweepArtifacts(ctx)
279+ if c.cfg.ReleaseSweeper != nil {
280+ c.cfg.ReleaseSweeper.Sweep(ctx)
281+ }
279282 }
280283
281284 // removeOrphans deletes what a file store (logs, summaries) holds for runs that
@@ -323,8 +326,6 @@ func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error {
323326 c.locks.Delete(id)
324327 }
325328 err := errors.Join(c.cfg.Logs.RemoveRuns(ctx, runIDs), c.cfg.Summaries.RemoveRuns(ctx, runIDs))
326- if c.artifactsOn() {
327- err = errors.Join(err, c.cfg.ArtifactFiles.RemoveRuns(ctx, runIDs))
328- }
329+ err = errors.Join(err, c.removeArtifactRuns(ctx, runIDs))
329330 return err
330331 }
M internal/usecase/events.go +25 -0
@@ -171,6 +171,14 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
171171 return repoSnapshot(ev.Visibility, ev.CreatedAt)
172172 case domain.MirrorRecovered:
173173 return repoSnapshot(ev.Visibility, ev.CreatedAt)
174+ case domain.ReleasePublished:
175+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
176+ case domain.ReleaseUpdated:
177+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
178+ case domain.ReleaseDeleted:
179+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
180+ case domain.ReleaseUnpublished:
181+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
174182 case domain.PushEvent:
175183 if ev.CreatedAt.IsZero() {
176184 return bufferedEvent{} // repository unknown at publish time: nobody is told
@@ -531,6 +539,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
531539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
532540 case domain.MirrorRecovered:
533541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.ReleasePublished:
543+ return e.deliverRelease(ctx, w, ev.Release, be, out, send)
544+ case domain.ReleaseUpdated:
545+ return e.deliverRelease(ctx, w, ev.Release, be, out, send)
546+ case domain.ReleaseDeleted:
547+ return e.deliverRelease(ctx, w, ev.Release, be, out, send)
548+ case domain.ReleaseUnpublished:
549+ return e.deliverRelease(ctx, w, ev.Release, be, out, send)
534550 case domain.PushEvent:
535551 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
536552 case domain.MergeRequestOpened:
@@ -646,6 +662,15 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
646662 return send(out)
647663 }
648664
665+// deliverRelease sends a release event. The event of a draft goes only to
666+// release writers: a draft is invisible to every other reader.
667+func (e *Events) deliverRelease(ctx context.Context, w *watch, rel domain.Release, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
668+ if rel.Draft && !releaseWriter(w.p, rel.Repo) {
669+ return nil
670+ }
671+ return e.deliverRepo(ctx, w, rel.Repo, false, be, out, send)
672+}
673+
649674 // isIssueEvent reports whether ev is about an issue.
650675 func isIssueEvent(ev domain.Event) bool {
651676 switch ev.(type) {
M internal/usecase/events_test.go +95 -0
@@ -1034,3 +1034,98 @@ func TestIssueReferencedRepoOnly(t *testing.T) {
10341034 }
10351035 })
10361036 }
1037+
1038+func TestDraftReleaseEventsReachOnlyReleaseWriters(t *testing.T) {
1039+ t.Parallel()
1040+ synctest.Test(t, func(t *testing.T) {
1041+ r := newEventRig(t, 64)
1042+ pub := r.repo("pub", domain.VisibilityPublic)
1043+ other := r.repo("other", domain.VisibilityPublic)
1044+ ctx, cancel := context.WithCancel(context.Background())
1045+ defer cancel()
1046+
1047+ job := func(repo domain.RepoName, level string) domain.Principal {
1048+ return domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: repo, Permissions: map[string]string{"contents": level}})
1049+ }
1050+ writerBranch := scopedToken(domain.ScopeReleaseWrite, []string{"agent/"}, "pub")
1051+ cases := []struct {
1052+ name string
1053+ p domain.Principal
1054+ want bool
1055+ }{
1056+ {"anonymous", domain.Anonymous(), false},
1057+ {"reader", tokenPrincipal(domain.ScopeRepoRead), false},
1058+ {"branch-limited writer", writerBranch, false},
1059+ {"job token of another repo", job(other, "write"), false},
1060+ {"job token contents:read", job(pub, "read"), false},
1061+ {"release:write token", tokenPrincipal(domain.ScopeReleaseWrite, "pub"), true},
1062+ {"job token contents:write", job(pub, "write"), true},
1063+ }
1064+
1065+ anchor := r.watch(ctx, domain.Root(), ports.WatchParams{})
1066+ synctest.Wait()
1067+ r.push(pub, 1)
1068+ anchorID := anchor.next(t).ID
1069+
1070+ draft := domain.Release{ID: 1, Repo: pub, Tag: "d", Draft: true}
1071+ published := domain.Release{ID: 2, Repo: pub, Tag: "v1"}
1072+ events := func(rel domain.Release) []domain.Event {
1073+ return []domain.Event{
1074+ domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic},
1075+ domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic},
1076+ domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic},
1077+ domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic},
1078+ }
1079+ }
1080+ check := func(t *testing.T, w *watcher, want bool) {
1081+ t.Helper()
1082+ if want {
1083+ for _, ev := range events(draft) {
1084+ require.Equal(t, ev.EventKind(), w.next(t).Event.EventKind())
1085+ }
1086+ }
1087+ for _, ev := range events(published) {
1088+ e := w.next(t).Event
1089+ require.Equal(t, ev.EventKind(), e.EventKind())
1090+ require.EqualValues(t, 2, releaseOf(e))
1091+ }
1092+ w.quiet(t, r, pub)
1093+ }
1094+
1095+ var live []*watcher
1096+ for _, tc := range cases {
1097+ live = append(live, r.watch(ctx, tc.p, ports.WatchParams{}))
1098+ }
1099+ synctest.Wait()
1100+ for _, ev := range events(draft) {
1101+ r.feed <- ev
1102+ }
1103+ for _, ev := range events(published) {
1104+ r.feed <- ev
1105+ }
1106+ for i, tc := range cases {
1107+ t.Log("live", tc.name)
1108+ check(t, live[i], tc.want)
1109+ }
1110+ for _, tc := range cases {
1111+ t.Log("replay", tc.name)
1112+ w := r.watch(ctx, tc.p, ports.WatchParams{ResumeAfter: anchorID})
1113+ check(t, w, tc.want)
1114+ }
1115+ })
1116+}
1117+
1118+// releaseOf returns the id of the release an event is about.
1119+func releaseOf(ev domain.Event) int64 {
1120+ switch ev := ev.(type) {
1121+ case domain.ReleasePublished:
1122+ return ev.Release.ID
1123+ case domain.ReleaseUpdated:
1124+ return ev.Release.ID
1125+ case domain.ReleaseDeleted:
1126+ return ev.Release.ID
1127+ case domain.ReleaseUnpublished:
1128+ return ev.Release.ID
1129+ }
1130+ return 0
1131+}
M internal/usecase/pushes.go +21 -14
@@ -17,20 +17,21 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20- links ports.IssueLinkTrigger
21- mirror ports.MirrorTrigger // nil: no outbound mirror
22- meta ports.RepoMetaStore
23- repos *Repos
24- git ports.GitRepos
25- bus ports.EventBus
26- clock ports.Clock
27- maint ports.MaintenanceScheduler
28- auth ports.Revalidator
29- fresh ports.RepoFreshener // nil: the reader needs no telling
30- ci ports.WorkflowTrigger // nil: no CI
31- trees ports.PushTreeReader // nil: pushes by job tokens are refused
32- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
33- mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
20+ releases ports.ReleaseTagTrigger
21+ links ports.IssueLinkTrigger
22+ mirror ports.MirrorTrigger // nil: no outbound mirror
23+ meta ports.RepoMetaStore
24+ repos *Repos
25+ git ports.GitRepos
26+ bus ports.EventBus
27+ clock ports.Clock
28+ maint ports.MaintenanceScheduler
29+ auth ports.Revalidator
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
31+ ci ports.WorkflowTrigger // nil: no CI
32+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
33+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
34+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3435 }
3536
3637 var (
@@ -45,6 +46,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4546 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4647 }
4748
49+// WithReleases enables synchronous release tag-deletion handling before the first push.
50+func (u *Pushes) WithReleases(t ports.ReleaseTagTrigger) *Pushes { u.releases = t; return u }
51+
4852 // WithIssueLinks enables synchronous best-effort issue indexing before the first push.
4953 func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
5054
@@ -272,6 +276,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
272276 if u.links != nil {
273277 u.links.OnBranchesMoved(ctx, name, updates)
274278 }
279+ if u.releases != nil {
280+ u.releases.OnRefsMoved(ctx, name, updates)
281+ }
275282 u.bus.Publish(ctx, domain.PushEvent{
276283 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
277284 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
A internal/usecase/releases.go +494 -0
@@ -0,0 +1,494 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// Releases manages release metadata and blob contents. Construct once per server.
20+type Releases struct {
21+ store ports.ReleaseStore
22+ blobs ports.BlobStore
23+ repos ports.RepoMetaStore
24+ reader ports.RepoReader
25+ tags ports.ReleaseTagWriter
26+ pushes ports.PushHookService
27+ bus ports.EventBus
28+ clock ports.Clock
29+ limits ports.ReleaseLimits
30+ uploads chan struct{}
31+ sweepMu sync.Mutex
32+ misses map[int64]int
33+}
34+
35+var _ ports.ReleaseService = (*Releases)(nil)
36+var _ ports.ReleaseTagTrigger = (*Releases)(nil)
37+var _ ports.ReleaseRepoCleaner = (*Releases)(nil)
38+var _ ports.ReleaseSweeper = (*Releases)(nil)
39+
40+// NewReleases constructs the release service. Zero limits select the defaults.
41+// pushes must be the shared Pushes instance, so created tags notify every hook.
42+func NewReleases(store ports.ReleaseStore, blobs ports.BlobStore, repos ports.RepoMetaStore, reader ports.RepoReader, tags ports.ReleaseTagWriter, pushes ports.PushHookService, bus ports.EventBus, clock ports.Clock, limits ports.ReleaseLimits) *Releases {
43+ if limits.AssetMax == 0 {
44+ limits.AssetMax = domain.DefaultReleaseAssetMax
45+ }
46+ if limits.AssetsMax == 0 {
47+ limits.AssetsMax = domain.DefaultReleaseAssetsMax
48+ }
49+ if limits.Quota == 0 {
50+ limits.Quota = domain.DefaultReleaseQuota
51+ }
52+ return &Releases{store: store, blobs: blobs, repos: repos, reader: reader, tags: tags, pushes: pushes, bus: bus, clock: clock, limits: limits, uploads: make(chan struct{}, 16), misses: make(map[int64]int)}
53+}
54+
55+func (r *Releases) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
56+ repo, err := lookupRepo(ctx, r.repos, name)
57+ if err == nil && !canRead(p, repo) {
58+ err = domain.ErrNotFound
59+ }
60+ return repo, err
61+}
62+func releaseWriter(p domain.Principal, name domain.RepoName) bool {
63+ return !p.IsBranchLimited() && (p.Allows(domain.ScopeReleaseWrite, name) || p.JobPermits(domain.PermContents, domain.PermissionWrite, name))
64+}
65+func (r *Releases) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
66+ repo, err := r.readable(ctx, p, name)
67+ if err != nil {
68+ return repo, err
69+ }
70+ if p.IsBranchLimited() {
71+ return repo, fmt.Errorf("%w: a branch-limited token cannot manage releases", domain.ErrForbidden)
72+ }
73+ if releaseWriter(p, name) {
74+ return repo, nil
75+ }
76+ if p.IsAnonymous() {
77+ return repo, domain.ErrUnauthenticated
78+ }
79+ return repo, domain.ErrForbidden
80+}
81+func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn func(*ports.ReleaseWrite) error) error {
82+ const releaseTries = 256
83+ for range releaseTries {
84+ if err := ctx.Err(); err != nil {
85+ return err
86+ }
87+ used, v, err := r.store.ReleaseUsage(ctx, repo)
88+ if err != nil {
89+ return err
90+ }
91+ w := ports.ReleaseWrite{Repo: repo, Used: used, UsageVersion: v}
92+ if err = fn(&w); err != nil {
93+ return err
94+ }
95+ err = r.store.ApplyReleases(ctx, w)
96+ if errors.Is(err, domain.ErrConflict) {
97+ continue
98+ }
99+ return err
100+ }
101+ return fmt.Errorf("%w: releases of %s keep changing, try again", domain.ErrUnavailable, repo)
102+}
103+func (r *Releases) resolve(ctx context.Context, repo domain.RepoName, rev string) (domain.ObjectID, error) {
104+ v, err := domain.ParseRevision(rev)
105+ if err != nil {
106+ return domain.ObjectID{}, err
107+ }
108+ got, err := r.reader.ResolveRevision(ctx, repo, v)
109+ return got.CommitID, err
110+}
111+func (r *Releases) bind(ctx context.Context, p domain.Principal, rel *domain.Release, target string, create bool) error {
112+ oid, err := r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag)
113+ if errors.Is(err, domain.ErrNotFound) {
114+ if rel.Draft {
115+ rel.TargetCommitish = ""
116+ return nil
117+ }
118+ if !create || target == "" {
119+ return fmt.Errorf("%w: tag %q does not exist; give target_commitish to create it", domain.ErrPreconditionFailed, rel.Tag)
120+ }
121+ if r.pushes == nil {
122+ return fmt.Errorf("%w: release tag notifications are not configured", domain.ErrUnavailable)
123+ }
124+ oid, err = r.resolve(ctx, rel.Repo, target)
125+ if err != nil {
126+ return err
127+ }
128+ err = r.tags.CreateTag(ctx, rel.Repo, rel.Tag, oid)
129+ if errors.Is(err, domain.ErrAlreadyExists) {
130+ oid, err = r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag)
131+ } else if err == nil {
132+ err = r.pushes.Record(ctx, p, rel.Repo, []domain.RefUpdate{{Ref: "refs/tags/" + rel.Tag, Old: domain.ZeroOID, New: oid.String()}})
133+ }
134+ }
135+ if err != nil {
136+ return err
137+ }
138+ rel.TargetCommitish = oid.String()
139+ return nil
140+}
141+func (r *Releases) pin(ctx context.Context, w *ports.ReleaseWrite, rel domain.Release) error {
142+ if !rel.MakeLatest {
143+ return nil
144+ }
145+ all, err := r.store.ListReleases(ctx, rel.Repo)
146+ if err != nil {
147+ return err
148+ }
149+ for _, other := range all {
150+ if other.ID != rel.ID && other.MakeLatest {
151+ other.MakeLatest = false
152+ w.PutReleases = append(w.PutReleases, other)
153+ }
154+ }
155+ return nil
156+}
157+func (r *Releases) emit(ctx context.Context, repo domain.Repo, rel domain.Release, kind string) {
158+ at := r.clock.Now()
159+ var ev domain.Event
160+ switch kind {
161+ case "published":
162+ ev = domain.ReleasePublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
163+ case "updated":
164+ ev = domain.ReleaseUpdated{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
165+ case "deleted":
166+ ev = domain.ReleaseDeleted{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
167+ case "unpublished":
168+ ev = domain.ReleaseUnpublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
169+ }
170+ r.bus.Publish(ctx, ev)
171+}
172+
173+// CreateRelease creates a release, optionally creating its tag when publishing.
174+func (r *Releases) CreateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error) {
175+ repo, err := r.writable(ctx, p, name)
176+ if err != nil {
177+ return domain.Release{}, err
178+ }
179+ rel := domain.Release{Repo: name, Draft: params.Draft, Prerelease: params.Prerelease, MakeLatest: params.MakeLatest, Author: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()}
180+ if rel.Tag, err = domain.ParseReleaseTag(params.Tag); err != nil {
181+ return rel, err
182+ }
183+ if rel.Title, err = domain.ParseReleaseTitle(params.Title); err != nil {
184+ return rel, err
185+ }
186+ if rel.Notes, err = domain.ParseReleaseNotes(params.Notes); err != nil {
187+ return rel, err
188+ }
189+ if _, err = r.store.GetReleaseByTag(ctx, name, rel.Tag); err == nil {
190+ return rel, domain.ErrAlreadyExists
191+ } else if !errors.Is(err, domain.ErrNotFound) {
192+ return rel, err
193+ }
194+ if err = r.bind(ctx, p, &rel, params.TargetCommitish, true); err != nil {
195+ return rel, err
196+ }
197+ if rel.Draft {
198+ rel.MakeLatest = false
199+ } else {
200+ rel.PublishedAt = r.clock.Now()
201+ }
202+ rel.ID, err = r.store.NextReleaseID(ctx)
203+ if err != nil {
204+ return rel, err
205+ }
206+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
207+ if err := r.pin(ctx, w, rel); err != nil {
208+ return err
209+ }
210+ w.PutReleases = append(w.PutReleases, rel)
211+ return nil
212+ })
213+ if err != nil {
214+ return domain.Release{}, err
215+ }
216+ if !rel.Draft {
217+ r.emit(ctx, repo, rel, "published")
218+ }
219+ return r.store.GetRelease(ctx, name, rel.ID)
220+}
221+
222+// GetRelease resolves a visible release by exactly one selector.
223+func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
224+ if _, err := r.readable(ctx, p, name); err != nil {
225+ return domain.Release{}, err
226+ }
227+ n := 0
228+ if ref.ID != 0 {
229+ n++
230+ }
231+ if ref.Tag != "" {
232+ n++
233+ }
234+ if ref.Latest {
235+ n++
236+ }
237+ if n != 1 {
238+ return domain.Release{}, domain.ErrInvalid
239+ }
240+ var rel domain.Release
241+ var err error
242+ switch {
243+ case ref.ID != 0:
244+ rel, err = r.store.GetRelease(ctx, name, ref.ID)
245+ case ref.Tag != "":
246+ rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
247+ default:
248+ var all []domain.Release
249+ all, err = r.store.ListReleases(ctx, name)
250+ if err == nil {
251+ var ok bool
252+ rel, ok = domain.LatestRelease(all)
253+ if !ok {
254+ err = domain.ErrNotFound
255+ }
256+ }
257+ }
258+ if err == nil && rel.Draft && !releaseWriter(p, name) {
259+ err = domain.ErrNotFound
260+ }
261+ return rel, err
262+}
263+
264+// ListReleases lists visible releases newest first, with an ID cursor.
265+func (r *Releases) ListReleases(ctx context.Context, p domain.Principal, name domain.RepoName, size int, token string) (ports.ReleasePage, error) {
266+ var out ports.ReleasePage
267+ if _, err := r.readable(ctx, p, name); err != nil {
268+ return out, err
269+ }
270+ if size == 0 {
271+ size = 30
272+ }
273+ n, err := pageSize(size)
274+ if err != nil {
275+ return out, err
276+ }
277+ var cursor int64
278+ if token != "" {
279+ cursor, err = strconv.ParseInt(token, 10, 64)
280+ if err != nil || cursor <= 0 {
281+ return out, domain.ErrInvalidPageToken
282+ }
283+ }
284+ all, err := r.store.ListReleases(ctx, name)
285+ if err != nil {
286+ return out, err
287+ }
288+ slices.SortFunc(all, func(a, b domain.Release) int {
289+ if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
290+ return c
291+ }
292+ if a.ID > b.ID {
293+ return -1
294+ }
295+ if a.ID < b.ID {
296+ return 1
297+ }
298+ return 0
299+ })
300+ start := token == ""
301+ writer := releaseWriter(p, name)
302+ for _, rel := range all {
303+ if rel.Draft && !writer {
304+ continue // a hidden draft is not even a valid cursor
305+ }
306+ if !start {
307+ if rel.ID == cursor {
308+ start = true
309+ }
310+ continue
311+ }
312+ if len(out.Releases) == n {
313+ out.NextPageToken = strconv.FormatInt(out.Releases[n-1].ID, 10)
314+ break
315+ }
316+ out.Releases = append(out.Releases, rel)
317+ }
318+ if !start {
319+ return out, domain.ErrInvalidPageToken
320+ }
321+ return out, nil
322+}
323+
324+// UpdateRelease changes only fields whose pointers are non-nil.
325+func (r *Releases) UpdateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error) {
326+ repo, err := r.writable(ctx, p, name)
327+ if err != nil {
328+ return domain.Release{}, err
329+ }
330+ var rel domain.Release
331+ kind := "updated"
332+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
333+ var err error
334+ rel, err = r.store.GetRelease(ctx, name, id)
335+ if err != nil {
336+ return err
337+ }
338+ wasDraft := rel.Draft
339+ if params.Tag != nil {
340+ rel.Tag, err = domain.ParseReleaseTag(*params.Tag)
341+ if err != nil {
342+ return err
343+ }
344+ }
345+ if params.Title != nil {
346+ rel.Title, err = domain.ParseReleaseTitle(*params.Title)
347+ if err != nil {
348+ return err
349+ }
350+ }
351+ if params.Notes != nil {
352+ rel.Notes, err = domain.ParseReleaseNotes(*params.Notes)
353+ if err != nil {
354+ return err
355+ }
356+ }
357+ if params.Draft != nil {
358+ rel.Draft = *params.Draft
359+ }
360+ if params.Prerelease != nil {
361+ rel.Prerelease = *params.Prerelease
362+ }
363+ if params.MakeLatest != nil {
364+ rel.MakeLatest = *params.MakeLatest
365+ }
366+ target := ""
367+ if params.TargetCommitish != nil {
368+ target = *params.TargetCommitish
369+ }
370+ if err = r.bind(ctx, p, &rel, target, target != ""); err != nil {
371+ return err
372+ }
373+ kind = "updated"
374+ if rel.Draft {
375+ rel.PublishedAt = time.Time{}
376+ rel.MakeLatest = false
377+ } else if wasDraft {
378+ rel.PublishedAt = r.clock.Now()
379+ kind = "published"
380+ }
381+ rel.UpdatedAt = r.clock.Now()
382+ if err = r.pin(ctx, w, rel); err != nil {
383+ return err
384+ }
385+ w.PutReleases = append(w.PutReleases, rel)
386+ return nil
387+ })
388+ if err != nil {
389+ return domain.Release{}, err
390+ }
391+ r.emit(ctx, repo, rel, kind)
392+ return r.store.GetRelease(ctx, name, id)
393+}
394+
395+// DeleteRelease removes metadata first, then its contents.
396+func (r *Releases) DeleteRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64) error {
397+ repo, err := r.writable(ctx, p, name)
398+ if err != nil {
399+ return err
400+ }
401+ var rel domain.Release
402+ var assets []domain.ReleaseAsset
403+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
404+ var err error
405+ rel, err = r.store.GetRelease(ctx, name, id)
406+ if err != nil {
407+ return err
408+ }
409+ assets, err = r.store.ListAssets(ctx, name, id)
410+ if err != nil {
411+ return err
412+ }
413+ w.DeleteReleases = []domain.Release{rel}
414+ w.DeleteAssets = assets
415+ for _, a := range assets {
416+ w.Used -= a.Charged
417+ }
418+ return nil
419+ })
420+ if err != nil {
421+ return err
422+ }
423+ r.removeAssetBlobs(context.WithoutCancel(ctx), assets)
424+ r.emit(ctx, repo, rel, "deleted")
425+ return nil
426+}
427+
428+// OnRefsMoved unpublishes releases of deleted tags; recreated tags stay drafts.
429+func (r *Releases) OnRefsMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
430+ for _, u := range updates {
431+ if !strings.HasPrefix(u.Ref, "refs/tags/") || u.New != domain.ZeroOID {
432+ continue
433+ }
434+ var changed bool
435+ var rel domain.Release
436+ err := r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
437+ changed = false
438+ var err error
439+ rel, err = r.store.GetReleaseByTag(ctx, name, strings.TrimPrefix(u.Ref, "refs/tags/"))
440+ if err != nil {
441+ return err
442+ }
443+ if rel.Draft {
444+ return nil
445+ }
446+ rel.Draft = true
447+ rel.MakeLatest = false
448+ rel.PublishedAt = time.Time{}
449+ rel.UpdatedAt = r.clock.Now()
450+ w.PutReleases = []domain.Release{rel}
451+ changed = true
452+ return nil
453+ })
454+ if errors.Is(err, domain.ErrNotFound) {
455+ continue
456+ }
457+ if err != nil {
458+ ctxLog(ctx).Error().Err(err).Msg("unpublish release after tag deletion")
459+ continue
460+ }
461+ if changed {
462+ repo, err := lookupRepo(ctx, r.repos, name)
463+ if err == nil {
464+ r.emit(ctx, repo, rel, "unpublished")
465+ }
466+ }
467+ }
468+}
469+
470+// AssetsOf snapshots assets before the repository's metadata is removed.
471+func (r *Releases) AssetsOf(ctx context.Context, name domain.RepoName) ([]domain.ReleaseAsset, error) {
472+ all, err := r.store.AllAssets(ctx)
473+ if err != nil {
474+ return nil, err
475+ }
476+ return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.Repo != name }), nil
477+}
478+
479+// OnRepoDeleted removes contents after repository metadata has been deleted.
480+func (r *Releases) OnRepoDeleted(ctx context.Context, _ domain.RepoName, assets []domain.ReleaseAsset) {
481+ r.removeAssetBlobs(ctx, assets)
482+}
483+func (r *Releases) removeAssetBlobs(ctx context.Context, assets []domain.ReleaseAsset) {
484+ for _, a := range assets {
485+ if a.MultipartID != "" {
486+ if err := r.blobs.AbortMultipart(ctx, a.BlobKey(), a.MultipartID); err != nil {
487+ ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("abort release asset multipart")
488+ }
489+ }
490+ if err := r.blobs.Delete(ctx, a.BlobKey()); err != nil {
491+ ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("remove release asset blob")
492+ }
493+ }
494+}
A internal/usecase/releases_assets.go +441 -0
@@ -0,0 +1,441 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "slices"
13+ "strings"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// UploadAsset reserves quota before reading, stores bytes, then publishes metadata.
21+func (r *Releases) UploadAsset(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UploadAssetParams, body io.Reader) (result domain.ReleaseAsset, err error) {
22+ repo, err := r.writable(ctx, p, name)
23+ if err != nil {
24+ return result, err
25+ }
26+ if params.Size < 0 || params.Size > r.limits.AssetMax {
27+ return result, fmt.Errorf("%w: asset size must be between zero and %s", domain.ErrResourceExhausted, sizeText(r.limits.AssetMax))
28+ }
29+ a := domain.ReleaseAsset{Repo: name, ReleaseID: params.ReleaseID, Size: params.Size, Charged: params.Size, State: domain.ReleaseAssetUploading, Uploader: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()}
30+ if a.Name, err = domain.ParseReleaseAssetName(params.Name); err != nil {
31+ return result, err
32+ }
33+ if a.Label, err = domain.ParseReleaseAssetLabel(params.Label); err != nil {
34+ return result, err
35+ }
36+ if a.ContentType, err = domain.ParseReleaseContentType(params.ContentType); err != nil {
37+ return result, err
38+ }
39+ select {
40+ case r.uploads <- struct{}{}:
41+ defer func() { <-r.uploads }()
42+ default:
43+ return result, fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted)
44+ }
45+ a.ID, err = r.store.NextReleaseID(ctx)
46+ if err != nil {
47+ return result, err
48+ }
49+ var replaced []domain.ReleaseAsset
50+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
51+ replaced = nil
52+ if _, err := r.store.GetRelease(ctx, name, a.ReleaseID); err != nil {
53+ if errors.Is(err, domain.ErrNotFound) {
54+ owner, lookupErr := r.store.ReleaseRepo(ctx, a.ReleaseID)
55+ if lookupErr == nil && owner != name {
56+ return fmt.Errorf("%w: release belongs to another repository", domain.ErrPreconditionFailed)
57+ }
58+ }
59+ return err
60+ }
61+ all, err := r.store.ListAssets(ctx, name, a.ReleaseID)
62+ if err != nil {
63+ return err
64+ }
65+ for _, old := range all {
66+ if old.Name != a.Name {
67+ continue
68+ }
69+ if (old.State != domain.ReleaseAssetUploading || r.clock.Now().Sub(old.UpdatedAt) <= 60*time.Second) && (old.State != domain.ReleaseAssetUploaded || !params.Replace) {
70+ return fmt.Errorf("%w: an asset named %q already exists", domain.ErrAlreadyExists, a.Name)
71+ }
72+ replaced = append(replaced, old)
73+ w.DeleteAssets = append(w.DeleteAssets, old)
74+ w.Used -= old.Charged
75+ }
76+ if int64(len(all)-len(replaced)) >= r.limits.AssetsMax {
77+ return fmt.Errorf("%w: release asset count limit is %d", domain.ErrResourceExhausted, r.limits.AssetsMax)
78+ }
79+ if a.Size > r.limits.Quota-w.Used {
80+ return fmt.Errorf("%w: release quota is %s", domain.ErrResourceExhausted, sizeText(r.limits.Quota))
81+ }
82+ w.Used += a.Charged
83+ w.PutAssets = []domain.ReleaseAsset{a}
84+ return nil
85+ })
86+ if err != nil {
87+ return result, err
88+ }
89+ r.removeAssetBlobs(context.WithoutCancel(ctx), replaced)
90+ body = releaseContextReader{ctx: ctx, reader: body}
91+ // From here every failure removes the reservation and any completed blob.
92+ defer func() {
93+ if err != nil {
94+ clean := context.WithoutCancel(ctx)
95+ if e := r.removeAssetRecord(clean, a, nil); e != nil && !errors.Is(e, domain.ErrNotFound) {
96+ ctxLog(ctx).Error().Err(e).Int64("asset", a.ID).Msg("refund failed release upload")
97+ }
98+ r.removeAssetBlobs(clean, []domain.ReleaseAsset{a})
99+ }
100+ }()
101+ if a.Size <= ports.BlobPartSize {
102+ var info ports.BlobInfo
103+ limited := &io.LimitedReader{R: body, N: a.Size}
104+ info, err = r.blobs.Put(ctx, a.BlobKey(), limited, a.Size)
105+ if err != nil {
106+ if limited.N > 0 && errors.Is(err, domain.ErrInvalid) {
107+ err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid)
108+ }
109+ return result, err
110+ }
111+ a.Digest = info.Digest
112+ if !validReleaseDigest(a.Digest) {
113+ return result, fmt.Errorf("%w: blob store returned no SHA-256 digest", domain.ErrInvalid)
114+ }
115+ } else {
116+ var upload ports.MultipartUpload
117+ upload, err = r.blobs.NewMultipart(ctx, a.BlobKey())
118+ if err != nil {
119+ return result, err
120+ }
121+ completed := false
122+ defer func() {
123+ if !completed {
124+ if e := upload.Abort(context.WithoutCancel(ctx)); e != nil {
125+ ctxLog(ctx).Error().Err(e).Msg("abort failed release upload")
126+ }
127+ }
128+ }()
129+ a.MultipartID = upload.ID()
130+ err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error { current.MultipartID = a.MultipartID; return nil })
131+ if err != nil {
132+ return result, err
133+ }
134+ hash := sha256.New()
135+ buf := make([]byte, ports.BlobPartSize)
136+ remaining := a.Size
137+ for part := 1; remaining > 0; part++ {
138+ if part > ports.BlobMaxParts {
139+ return result, domain.ErrResourceExhausted
140+ }
141+ want := min(remaining, int64(len(buf)))
142+ var n int
143+ n, err = io.ReadFull(body, buf[:want])
144+ if err != nil {
145+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
146+ err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid)
147+ }
148+ return result, err
149+ }
150+ hash.Write(buf[:n])
151+ err = upload.UploadPart(ctx, part, bytes.NewReader(buf[:n]), int64(n))
152+ if err != nil {
153+ return result, err
154+ }
155+ remaining -= int64(n)
156+ }
157+ if err = releaseBodyEnd(body); err != nil {
158+ return result, err
159+ }
160+ _, err = upload.Complete(ctx)
161+ if err != nil {
162+ return result, err
163+ }
164+ completed = true
165+ a.Digest = fmt.Sprintf("sha256:%x", hash.Sum(nil))
166+ a.MultipartID = ""
167+ }
168+ if a.Size <= ports.BlobPartSize {
169+ if err = releaseBodyEnd(body); err != nil {
170+ return result, err
171+ }
172+ }
173+ err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error {
174+ if current.State != domain.ReleaseAssetUploading {
175+ return domain.ErrConflict
176+ }
177+ current.State = domain.ReleaseAssetUploaded
178+ current.Digest = a.Digest
179+ current.MultipartID = ""
180+ current.UpdatedAt = r.clock.Now()
181+ return nil
182+ })
183+ if err != nil {
184+ return result, err
185+ }
186+ result, err = r.store.GetAsset(ctx, a.ID)
187+ if err != nil {
188+ return result, err
189+ }
190+ rel, e := r.store.GetRelease(ctx, name, a.ReleaseID)
191+ if e == nil {
192+ r.emit(ctx, repo, rel, "updated")
193+ }
194+ ctxLog(ctx).Info().Int64("release", a.ReleaseID).Int64("asset", a.ID).Int64("size", a.Size).Msg("release asset uploaded")
195+ return result, nil
196+}
197+func validReleaseDigest(s string) bool {
198+ if len(s) != 71 || s[:7] != "sha256:" {
199+ return false
200+ }
201+ for _, c := range s[7:] {
202+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
203+ return false
204+ }
205+ }
206+ return true
207+}
208+
209+// releaseContextReader checks cancellation between reads, including multipart
210+// buffering and the final extra-byte check. The transport owns interrupting a
211+// body.Read already in progress.
212+type releaseContextReader struct {
213+ ctx context.Context
214+ reader io.Reader
215+}
216+
217+func (r releaseContextReader) Read(p []byte) (int, error) {
218+ if err := r.ctx.Err(); err != nil {
219+ return 0, err
220+ }
221+ return r.reader.Read(p)
222+}
223+
224+func releaseBodyEnd(body io.Reader) error {
225+ var extra [1]byte
226+ n, err := io.ReadFull(body, extra[:])
227+ if n > 0 {
228+ return fmt.Errorf("%w: body longer than size", domain.ErrInvalid)
229+ }
230+ if errors.Is(err, io.EOF) {
231+ return nil
232+ }
233+ return err
234+}
235+func (r *Releases) changeAsset(ctx context.Context, id int64, repo domain.RepoName, fn func(*domain.ReleaseAsset) error) error {
236+ return r.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error {
237+ a, err := r.store.GetAsset(ctx, id)
238+ if err != nil {
239+ return err
240+ }
241+ if a.Repo != repo {
242+ return domain.ErrNotFound
243+ }
244+ if err = fn(&a); err != nil {
245+ return err
246+ }
247+ w.PutAssets = []domain.ReleaseAsset{a}
248+ return nil
249+ })
250+}
251+
252+// removeAssetRecord conditionally removes the current record; sweep predicates
253+// are evaluated again after a conflict so a just-completed upload is protected.
254+func (r *Releases) removeAssetRecord(ctx context.Context, a domain.ReleaseAsset, predicate func(domain.ReleaseAsset) bool) error {
255+ return r.updateReleases(ctx, a.Repo, func(w *ports.ReleaseWrite) error {
256+ current, err := r.store.GetAsset(ctx, a.ID)
257+ if err != nil {
258+ return err
259+ }
260+ if current.Repo != a.Repo {
261+ return domain.ErrNotFound
262+ }
263+ if predicate != nil && !predicate(current) {
264+ return domain.ErrConflict
265+ }
266+ w.DeleteAssets = []domain.ReleaseAsset{current}
267+ w.Used -= current.Charged
268+ return nil
269+ })
270+}
271+
272+// ListAssets returns completed assets only, ordered by name.
273+func (r *Releases) ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
274+ if _, err := r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: id}); err != nil {
275+ return nil, err
276+ }
277+ all, err := r.store.ListAssets(ctx, repo, id)
278+ if err != nil {
279+ return nil, err
280+ }
281+ return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.State != domain.ReleaseAssetUploaded }), nil
282+}
283+
284+// GetAsset resolves an uploaded asset, enforcing its release's draft rule.
285+func (r *Releases) GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
286+ var a domain.ReleaseAsset
287+ var err error
288+ if ref.ID != 0 {
289+ if ref.Tag != "" || ref.Latest || ref.Name != "" {
290+ return a, domain.ErrInvalid
291+ }
292+ if _, err = r.readable(ctx, p, repo); err != nil {
293+ return a, err
294+ }
295+ a, err = r.store.GetAsset(ctx, ref.ID)
296+ if err != nil {
297+ return a, err
298+ }
299+ if a.Repo != repo {
300+ return domain.ReleaseAsset{}, domain.ErrNotFound
301+ }
302+ _, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: a.ReleaseID})
303+ } else {
304+ if ref.Name == "" {
305+ return a, domain.ErrInvalid
306+ }
307+ var rel domain.Release
308+ rel, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{Tag: ref.Tag, Latest: ref.Latest})
309+ if err != nil {
310+ return a, err
311+ }
312+ a, err = r.store.GetAssetByName(ctx, repo, rel.ID, ref.Name)
313+ }
314+ if err == nil && a.State != domain.ReleaseAssetUploaded {
315+ err = domain.ErrNotFound
316+ }
317+ return a, err
318+}
319+
320+// UpdateAsset changes an uploaded asset's name and label without moving bytes.
321+func (r *Releases) UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error) {
322+ meta, err := r.writable(ctx, p, repo)
323+ if err != nil {
324+ return domain.ReleaseAsset{}, err
325+ }
326+ a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id})
327+ if err != nil {
328+ return a, err
329+ }
330+ var newName, newLabel string
331+ if params.Name != nil {
332+ newName, err = domain.ParseReleaseAssetName(*params.Name)
333+ if err != nil {
334+ return a, err
335+ }
336+ }
337+ if params.Label != nil {
338+ newLabel, err = domain.ParseReleaseAssetLabel(*params.Label)
339+ if err != nil {
340+ return a, err
341+ }
342+ }
343+ err = r.changeAsset(ctx, id, repo, func(current *domain.ReleaseAsset) error {
344+ if current.State != domain.ReleaseAssetUploaded {
345+ return domain.ErrNotFound
346+ }
347+ if params.Name != nil {
348+ current.Name = newName
349+ }
350+ if params.Label != nil {
351+ current.Label = newLabel
352+ }
353+ current.UpdatedAt = r.clock.Now()
354+ return nil
355+ })
356+ if err != nil {
357+ return domain.ReleaseAsset{}, err
358+ }
359+ a, err = r.store.GetAsset(ctx, id)
360+ if err != nil {
361+ return a, err
362+ }
363+ if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil {
364+ r.emit(ctx, meta, rel, "updated")
365+ }
366+ return a, nil
367+}
368+
369+// DeleteAsset refunds quota before deleting the blob.
370+func (r *Releases) DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error {
371+ meta, err := r.writable(ctx, p, repo)
372+ if err != nil {
373+ return err
374+ }
375+ a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id})
376+ if err != nil {
377+ return err
378+ }
379+ if err = r.removeAssetRecord(ctx, a, nil); err != nil {
380+ return err
381+ }
382+ r.removeAssetBlobs(context.WithoutCancel(ctx), []domain.ReleaseAsset{a})
383+ if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil {
384+ r.emit(ctx, meta, rel, "updated")
385+ }
386+ return nil
387+}
388+
389+// DownloadAsset sends metadata followed by bounded chunks of the requested range.
390+func (r *Releases) DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
391+ a, err := r.GetAsset(ctx, p, repo, ref)
392+ if err != nil {
393+ return err
394+ }
395+ var body io.ReadCloser
396+ if a.Size == 0 && offset == 0 {
397+ // Blob stores reject Open on an empty object: stat it instead.
398+ if _, err = r.blobs.Stat(ctx, a.BlobKey()); err == nil {
399+ body = io.NopCloser(strings.NewReader(""))
400+ }
401+ } else {
402+ body, _, err = r.blobs.Open(ctx, a.BlobKey(), offset, length)
403+ }
404+ if err != nil {
405+ if errors.Is(err, domain.ErrNotFound) {
406+ ctxLog(ctx).Error().Int64("asset", a.ID).Msg("asset blob missing")
407+ }
408+ return err
409+ }
410+ defer func() { _ = body.Close() }()
411+ if err = send(ports.AssetChunk{Asset: &a}); err != nil {
412+ return err
413+ }
414+ buf := make([]byte, domain.CIArtifactChunk)
415+ for {
416+ if err = ctx.Err(); err != nil {
417+ return err
418+ }
419+ n, e := body.Read(buf)
420+ if n > 0 {
421+ if err = send(ports.AssetChunk{Data: buf[:n]}); err != nil {
422+ return err
423+ }
424+ }
425+ if errors.Is(e, io.EOF) {
426+ return nil
427+ }
428+ if e != nil {
429+ return e
430+ }
431+ }
432+}
433+
434+// Usage reports quota and configured limits to a repository reader.
435+func (r *Releases) Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error) {
436+ if _, err := r.readable(ctx, p, repo); err != nil {
437+ return ports.ReleaseUsage{}, err
438+ }
439+ used, _, err := r.store.ReleaseUsage(ctx, repo)
440+ return ports.ReleaseUsage{Used: used, Limits: r.limits}, err
441+}
A internal/usecase/releases_assets_test.go +308 -0
@@ -0,0 +1,308 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
20+ "github.com/stretchr/testify/mock"
21+ "github.com/stretchr/testify/require"
22+)
23+
24+func (r *releaseRig) upload(t *testing.T, rel domain.Release, n string, data []byte) domain.ReleaseAsset {
25+ t.Helper()
26+ a, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: n, Size: int64(len(data))}, bytes.NewReader(data))
27+ require.NoError(t, err)
28+ return a
29+}
30+func TestReleaseAssetRoundTrip(t *testing.T) {
31+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10, Quota: 10})
32+ rel := r.create(t, "v1", false)
33+ a := r.upload(t, rel, "binary", []byte("0123456789"))
34+ require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("0123456789"))), a.Digest)
35+ usage, err := r.svc.Usage(r.ctx, domain.Anonymous(), r.repo)
36+ require.NoError(t, err)
37+ require.EqualValues(t, 10, usage.Used)
38+ for _, tc := range []struct {
39+ offset, length int64
40+ want string
41+ err error
42+ }{{0, -1, "0123456789", nil}, {3, 4, "3456", nil}, {9, 1, "9", nil}, {10, -1, "", domain.ErrOutOfRange}, {11, -1, "", domain.ErrOutOfRange}} {
43+ var got bytes.Buffer
44+ metadata := false
45+ err := r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{Latest: true, Name: a.Name}, tc.offset, tc.length, func(c ports.AssetChunk) error {
46+ if c.Asset != nil {
47+ require.False(t, metadata)
48+ metadata = true
49+ require.Equal(t, a.ID, c.Asset.ID)
50+ } else {
51+ require.True(t, metadata)
52+ got.Write(c.Data)
53+ }
54+ return nil
55+ })
56+ require.ErrorIs(t, err, tc.err)
57+ require.Equal(t, tc.want, got.String())
58+ }
59+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "large", Size: 11}, bytes.NewReader(nil))
60+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
61+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "quota", Size: 1}, bytes.NewReader([]byte("x")))
62+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
63+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 0}, bytes.NewReader(nil))
64+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
65+ replacement, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 2, Replace: true}, bytes.NewReader([]byte("hi")))
66+ require.NoError(t, err)
67+ require.NotEqual(t, a.ID, replacement.ID)
68+ _, err = r.blobs.Stat(r.ctx, a.BlobKey())
69+ require.ErrorIs(t, err, domain.ErrNotFound)
70+ renamed := "new"
71+ label := "label"
72+ replacement, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, replacement.ID, ports.UpdateAssetParams{Name: &renamed, Label: &label})
73+ require.NoError(t, err)
74+ require.Equal(t, label, replacement.Label)
75+ list, err := r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID)
76+ require.NoError(t, err)
77+ require.Len(t, list, 1)
78+ require.Equal(t, renamed, list[0].Name)
79+ require.NoError(t, r.svc.DeleteAsset(r.ctx, domain.Root(), r.repo, replacement.ID))
80+ usage, err = r.svc.Usage(r.ctx, domain.Root(), r.repo)
81+ require.NoError(t, err)
82+ require.Zero(t, usage.Used)
83+}
84+func TestReleaseAssetCountBoundary(t *testing.T) {
85+ r := newReleaseRig(t, ports.ReleaseLimits{})
86+ rel := r.create(t, "v1", false)
87+ // Seed 999 records in one batch, then exercise the exact public boundary.
88+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
89+ require.NoError(t, err)
90+ w := ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v}
91+ for range 999 {
92+ id, err := r.store.Releases().NextReleaseID(r.ctx)
93+ require.NoError(t, err)
94+ w.PutAssets = append(w.PutAssets, domain.ReleaseAsset{ID: id, ReleaseID: rel.ID, Repo: r.repo, Name: fmt.Sprint(id), State: domain.ReleaseAssetUploaded})
95+ }
96+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, w))
97+ r.upload(t, rel, "thousandth", nil)
98+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "1001"}, bytes.NewReader(nil))
99+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
100+}
101+func TestReleaseAssetBadBodies(t *testing.T) {
102+ bodyErr := errors.New("reader failed")
103+ for _, size := range []int64{4, 9 << 20} {
104+ for _, kind := range []string{"short", "long", "error", "cancel"} {
105+ t.Run(fmt.Sprint(size, "/", kind), func(t *testing.T) {
106+ r := newReleaseRig(t, ports.ReleaseLimits{})
107+ rel := r.create(t, "v1", false)
108+ var body io.Reader
109+ switch kind {
110+ case "short":
111+ body = bytes.NewReader(make([]byte, size-1))
112+ case "long":
113+ body = bytes.NewReader(make([]byte, size+1))
114+ case "error":
115+ body = errReader{bodyErr}
116+ case "cancel":
117+ body = errReader{context.Canceled}
118+ }
119+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "bad", Size: size}, body)
120+ switch kind {
121+ case "error":
122+ require.ErrorIs(t, err, bodyErr)
123+ case "cancel":
124+ require.ErrorIs(t, err, context.Canceled)
125+ default:
126+ require.ErrorIs(t, err, domain.ErrInvalid)
127+ }
128+ all, err := r.store.Releases().AllAssets(r.ctx)
129+ require.NoError(t, err)
130+ require.Empty(t, all)
131+ u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
132+ require.NoError(t, err)
133+ require.Zero(t, u.Used)
134+ require.NoError(t, r.blobs.List(r.ctx, "release/", func(ports.BlobInfo) error { t.Error("blob left behind"); return nil }))
135+ require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart left behind"); return nil }))
136+ })
137+ }
138+ }
139+}
140+
141+type releaseBlobSpy struct {
142+ ports.BlobStore
143+ put, multipart int
144+ parts []int64
145+}
146+
147+func (s *releaseBlobSpy) Put(ctx context.Context, key string, b io.Reader, size int64) (ports.BlobInfo, error) {
148+ s.put++
149+ return s.BlobStore.Put(ctx, key, b, size)
150+}
151+func (s *releaseBlobSpy) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
152+ s.multipart++
153+ u, e := s.BlobStore.NewMultipart(ctx, key)
154+ return &releasePartSpy{MultipartUpload: u, spy: s}, e
155+}
156+
157+type releasePartSpy struct {
158+ ports.MultipartUpload
159+ spy *releaseBlobSpy
160+}
161+
162+func (s *releasePartSpy) UploadPart(ctx context.Context, n int, b io.Reader, size int64) error {
163+ s.spy.parts = append(s.spy.parts, size)
164+ return s.MultipartUpload.UploadPart(ctx, n, b, size)
165+}
166+func TestReleaseAssetUploadPaths(t *testing.T) {
167+ for _, size := range []int{1 << 20, 9 << 20} {
168+ t.Run(fmt.Sprint(size), func(t *testing.T) {
169+ r := newReleaseRig(t, ports.ReleaseLimits{})
170+ rel := r.create(t, "v1", false)
171+ spy := &releaseBlobSpy{BlobStore: r.blobs}
172+ bus := mocks.NewEventBus(t)
173+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
174+ r.svc = usecase.NewReleases(r.store.Releases(), spy, r.store, r.reader, r.tags, r.pushes, bus, r.clock, ports.ReleaseLimits{})
175+ data := bytes.Repeat([]byte("z"), size)
176+ a := r.upload(t, rel, "blob", data)
177+ require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256(data)), a.Digest)
178+ if size == 1<<20 {
179+ require.Equal(t, 1, spy.put)
180+ require.Zero(t, spy.multipart)
181+ } else {
182+ require.Zero(t, spy.put)
183+ require.Equal(t, 1, spy.multipart)
184+ require.Equal(t, []int64{8 << 20, 1 << 20}, spy.parts)
185+ }
186+ })
187+ }
188+}
189+func TestReleaseAssetStaleReplacementAndReleaseDelete(t *testing.T) {
190+ r := newReleaseRig(t, ports.ReleaseLimits{})
191+ rel := r.create(t, "v1", false)
192+ a := r.upload(t, rel, "pending", []byte("old"))
193+ current, err := r.store.Releases().GetAsset(r.ctx, a.ID)
194+ require.NoError(t, err)
195+ current.State = domain.ReleaseAssetUploading
196+ current.UpdatedAt = now.Add(-61 * time.Second)
197+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
198+ require.NoError(t, err)
199+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{current}}))
200+ b := r.upload(t, rel, "pending", []byte("new"))
201+ require.NotEqual(t, a.ID, b.ID)
202+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, rel.ID))
203+ _, err = r.blobs.Stat(r.ctx, b.BlobKey())
204+ require.ErrorIs(t, err, domain.ErrNotFound)
205+ u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
206+ require.NoError(t, err)
207+ require.Zero(t, u.Used)
208+}
209+
210+type releaseBlockingReader struct {
211+ entered chan<- struct{}
212+ release <-chan struct{}
213+}
214+
215+func (b releaseBlockingReader) Read([]byte) (int, error) {
216+ b.entered <- struct{}{}
217+ <-b.release
218+ return 0, io.EOF
219+}
220+func TestReleaseAssetConcurrencyCap(t *testing.T) {
221+ r := newReleaseRig(t, ports.ReleaseLimits{})
222+ rel := r.create(t, "v1", false)
223+ entered := make(chan struct{}, 16)
224+ release := make(chan struct{})
225+ var wg sync.WaitGroup
226+ errs := make(chan error, 16)
227+ for i := range 16 {
228+ wg.Go(func() {
229+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: fmt.Sprint(i), Size: 1}, releaseBlockingReader{entered, release})
230+ errs <- err
231+ })
232+ }
233+ // Every reader has a reservation and holds its slot before the 17th call.
234+ for range 16 {
235+ <-entered
236+ }
237+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "excess", Size: 0}, bytes.NewReader(nil))
238+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
239+ close(release)
240+ wg.Wait()
241+ close(errs)
242+ for err := range errs {
243+ require.ErrorIs(t, err, domain.ErrInvalid)
244+ }
245+}
246+
247+type errReader struct{ err error }
248+
249+func (r errReader) Read([]byte) (int, error) { return 0, r.err }
250+
251+func TestReleaseAssetDraftAccessAndRenameCollision(t *testing.T) {
252+ r := newReleaseRig(t, ports.ReleaseLimits{})
253+ rel := r.create(t, "draft", true)
254+ a := r.upload(t, rel, "a", []byte("abc"))
255+ b := r.upload(t, rel, "b", nil)
256+ _, err := r.svc.GetAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID})
257+ require.ErrorIs(t, err, domain.ErrNotFound)
258+ _, err = r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID)
259+ require.ErrorIs(t, err, domain.ErrNotFound)
260+ _, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateAssetParams{Name: &a.Name})
261+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
262+ got, err := r.svc.GetAsset(r.ctx, domain.Root(), r.repo, ports.AssetRef{Tag: rel.Tag, Name: a.Name})
263+ require.NoError(t, err)
264+ require.Equal(t, a.ID, got.ID)
265+ assets, err := r.svc.AssetsOf(r.ctx, r.repo)
266+ require.NoError(t, err)
267+ require.Len(t, assets, 2)
268+ require.NoError(t, r.store.Delete(r.ctx, r.repo))
269+ r.svc.OnRepoDeleted(r.ctx, r.repo, assets)
270+ _, err = r.blobs.Stat(r.ctx, a.BlobKey())
271+ require.ErrorIs(t, err, domain.ErrNotFound)
272+ all, err := r.store.Releases().AllAssets(r.ctx)
273+ require.NoError(t, err)
274+ require.Empty(t, all)
275+}
276+
277+func TestReleaseAssetQuotaAcrossReleasesAndWrongRepository(t *testing.T) {
278+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 3, Quota: 5})
279+ a := r.create(t, "a", false)
280+ b := r.create(t, "b", false)
281+ r.upload(t, a, "a", []byte("abc"))
282+ r.upload(t, b, "b", []byte("de"))
283+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: b.ID, Name: "extra", Size: 1}, bytes.NewReader([]byte("x")))
284+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
285+ other := name(t, "other")
286+ require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
287+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), other, ports.UploadAssetParams{ReleaseID: b.ID, Name: "wrong"}, bytes.NewReader(nil))
288+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
289+}
290+
291+func TestReleaseAssetDownloadZeroBytes(t *testing.T) {
292+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10, Quota: 10})
293+ rel := r.create(t, "v1", false)
294+ a := r.upload(t, rel, "empty", nil)
295+ var chunks []ports.AssetChunk
296+ err := r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID}, 0, -1, func(c ports.AssetChunk) error {
297+ chunks = append(chunks, c)
298+ return nil
299+ })
300+ require.NoError(t, err)
301+ require.Len(t, chunks, 1)
302+ require.Equal(t, a.ID, chunks[0].Asset.ID)
303+
304+ // A missing blob stays an error.
305+ require.NoError(t, r.blobs.Delete(r.ctx, a.BlobKey()))
306+ err = r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID}, 0, -1, func(ports.AssetChunk) error { return nil })
307+ require.ErrorIs(t, err, domain.ErrNotFound)
308+}
A internal/usecase/releases_github.go +387 -0
@@ -0,0 +1,387 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/url"
11+ "slices"
12+ "strconv"
13+ "strings"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// ReleasesGitHub builds the GitHub REST representations of releases. Authorization
20+// and all mutations remain in Releases; it owns no metadata or contents.
21+type ReleasesGitHub struct {
22+ rel *Releases
23+ owner, publicURL string
24+}
25+
26+var _ ports.GitHubReleaseAPI = (*ReleasesGitHub)(nil)
27+
28+// NewReleasesGitHub wraps the shared release service. publicURL is the external
29+// server URL (without the API prefix), used in upload and browser download URLs.
30+func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub {
31+ return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")}
32+}
33+
34+func (g *ReleasesGitHub) repo(owner, name string) (domain.RepoName, error) {
35+ if !domain.OwnerMatches(g.owner, owner) {
36+ return domain.RepoName{}, domain.ErrNotFound
37+ }
38+ rn, err := domain.ParseRepoName(strings.ToLower(name))
39+ if err != nil {
40+ return domain.RepoName{}, domain.ErrNotFound
41+ }
42+ return rn, nil
43+}
44+func (g *ReleasesGitHub) base(repo domain.RepoName) string {
45+ return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String())
46+}
47+func (g *ReleasesGitHub) user(label string) map[string]any {
48+ if label == "" || label == "root" || label == "owner" {
49+ label = g.owner
50+ }
51+ u := ghURLs{server: g.publicURL, api: g.publicURL + apiPath}
52+ return u.userObj(ghIdentity{login: label, id: stableID("user", label)}, false)
53+}
54+
55+// escapeReleaseTag preserves tag separators: the web routes join the tag path.
56+func escapeReleaseTag(tag string) string {
57+ parts := strings.Split(tag, "/")
58+ for i := range parts {
59+ parts[i] = url.PathEscape(parts[i])
60+ }
61+ return strings.Join(parts, "/")
62+}
63+func (g *ReleasesGitHub) assetObj(a domain.ReleaseAsset, tag string) ordered {
64+ return ordered{
65+ {"url", g.base(a.Repo) + "/releases/assets/" + strconv.FormatInt(a.ID, 10)},
66+ {"id", a.ID}, {"node_id", ""}, {"name", a.Name}, {"label", a.Label},
67+ {"uploader", g.user(a.Uploader)}, {"content_type", a.ContentType}, {"state", "uploaded"},
68+ {"size", a.Size}, {"digest", a.Digest}, {"download_count", 0},
69+ {"created_at", a.CreatedAt.UTC().Format(timeLayout)}, {"updated_at", a.UpdatedAt.UTC().Format(timeLayout)},
70+ {"browser_download_url", g.publicURL + "/" + a.Repo.String() + "/releases/download/" + url.PathEscape(tag) + "/" + url.PathEscape(a.Name)},
71+ }
72+}
73+func (g *ReleasesGitHub) releaseObj(ctx context.Context, p domain.Principal, r domain.Release) (ordered, error) {
74+ assets, err := g.rel.ListAssets(ctx, p, r.Repo, r.ID)
75+ if err != nil {
76+ return nil, err
77+ }
78+ objects := make([]ordered, 0, len(assets))
79+ for _, a := range assets {
80+ objects = append(objects, g.assetObj(a, r.Tag))
81+ }
82+ base := g.base(r.Repo) + "/releases/" + strconv.FormatInt(r.ID, 10)
83+ var published any
84+ if !r.Draft && !r.PublishedAt.IsZero() {
85+ published = r.PublishedAt.UTC().Format(timeLayout)
86+ }
87+ return ordered{
88+ {"url", base}, {"assets_url", base + "/assets"},
89+ {"upload_url", g.publicURL + apiPath + "/uploads/repos/" + url.PathEscape(g.owner) + "/" + r.Repo.String() + "/releases/" + strconv.FormatInt(r.ID, 10) + "/assets{?name,label}"},
90+ {"html_url", g.publicURL + "/" + r.Repo.String() + "/releases/tag/" + escapeReleaseTag(r.Tag)},
91+ {"id", r.ID}, {"author", g.user(r.Author)}, {"node_id", ""}, {"tag_name", r.Tag}, {"target_commitish", r.TargetCommitish},
92+ {"name", r.Title}, {"draft", r.Draft}, {"prerelease", r.Prerelease}, {"created_at", r.CreatedAt.UTC().Format(timeLayout)},
93+ {"published_at", published}, {"assets", objects}, {"tarball_url", g.base(r.Repo) + "/tarball/" + escapeReleaseTag(r.Tag)},
94+ {"zipball_url", nil}, {"body", r.Notes},
95+ }, nil
96+}
97+func (g *ReleasesGitHub) marshalRelease(ctx context.Context, p domain.Principal, r domain.Release) ([]byte, error) {
98+ o, err := g.releaseObj(ctx, p, r)
99+ if err != nil {
100+ return nil, err
101+ }
102+ return json.Marshal(o)
103+}
104+func releaseFields(body []byte) (map[string]json.RawMessage, error) {
105+ var fields map[string]json.RawMessage
106+ if err := json.Unmarshal(body, &fields); err != nil || fields == nil {
107+ return nil, fmt.Errorf("%w: JSON object required", domain.ErrInvalid)
108+ }
109+ return fields, nil
110+}
111+func releaseField[T any](fields map[string]json.RawMessage, key string) (*T, error) {
112+ b, ok := fields[key]
113+ if !ok {
114+ return nil, nil
115+ }
116+ var v T
117+ if string(b) == "null" || json.Unmarshal(b, &v) != nil {
118+ return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, key)
119+ }
120+ return &v, nil
121+}
122+func releaseParams(body []byte) (ports.UpdateReleaseParams, error) {
123+ var p ports.UpdateReleaseParams
124+ f, err := releaseFields(body)
125+ if err != nil {
126+ return p, err
127+ }
128+ for key, dst := range map[string]**string{"tag_name": &p.Tag, "target_commitish": &p.TargetCommitish, "name": &p.Title, "body": &p.Notes} {
129+ *dst, err = releaseField[string](f, key)
130+ if err != nil {
131+ return p, err
132+ }
133+ }
134+ for key, dst := range map[string]**bool{"draft": &p.Draft, "prerelease": &p.Prerelease} {
135+ *dst, err = releaseField[bool](f, key)
136+ if err != nil {
137+ return p, err
138+ }
139+ }
140+ latest, err := releaseField[string](f, "make_latest")
141+ if err != nil {
142+ return p, err
143+ }
144+ if latest != nil {
145+ switch *latest {
146+ case "true":
147+ v := true
148+ p.MakeLatest = &v
149+ case "false":
150+ v := false
151+ p.MakeLatest = &v
152+ case "legacy":
153+ default:
154+ return p, fmt.Errorf("%w: make_latest", domain.ErrInvalid)
155+ }
156+ }
157+ return p, nil
158+}
159+func value[T any](p *T) (v T) {
160+ if p != nil {
161+ return *p
162+ }
163+ return v
164+}
165+
166+// CreateRelease accepts GitHub's create body and returns its representation.
167+func (g *ReleasesGitHub) CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) {
168+ rn, err := g.repo(owner, repo)
169+ if err != nil {
170+ return nil, err
171+ }
172+ params, err := releaseParams(body)
173+ if err != nil {
174+ return nil, err
175+ }
176+ if params.Tag == nil || *params.Tag == "" {
177+ return nil, fmt.Errorf("%w: tag_name", domain.ErrInvalid)
178+ }
179+ r, err := g.rel.CreateRelease(ctx, p, rn, ports.CreateReleaseParams{Tag: *params.Tag, TargetCommitish: value(params.TargetCommitish), Title: value(params.Title), Notes: value(params.Notes), Draft: value(params.Draft), Prerelease: value(params.Prerelease), MakeLatest: value(params.MakeLatest)})
180+ if err != nil {
181+ return nil, err
182+ }
183+ ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", r.ID).Msg("github release created")
184+ return g.marshalRelease(ctx, p, r)
185+}
186+
187+// GetRelease resolves an id, tag or latest selector.
188+func (g *ReleasesGitHub) GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ports.ReleaseRef) ([]byte, error) {
189+ rn, err := g.repo(owner, repo)
190+ if err != nil {
191+ return nil, err
192+ }
193+ r, err := g.rel.GetRelease(ctx, p, rn, ref)
194+ if err != nil {
195+ return nil, err
196+ }
197+ return g.marshalRelease(ctx, p, r)
198+}
199+
200+// UpdateRelease changes only present fields, including explicit false and empty strings.
201+func (g *ReleasesGitHub) UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) {
202+ rn, err := g.repo(owner, repo)
203+ if err != nil {
204+ return nil, err
205+ }
206+ params, err := releaseParams(body)
207+ if err != nil {
208+ return nil, err
209+ }
210+ r, err := g.rel.UpdateRelease(ctx, p, rn, id, params)
211+ if err != nil {
212+ return nil, err
213+ }
214+ ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release updated")
215+ return g.marshalRelease(ctx, p, r)
216+}
217+
218+// DeleteRelease removes a release and all its assets.
219+func (g *ReleasesGitHub) DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error {
220+ rn, err := g.repo(owner, repo)
221+ if err != nil {
222+ return err
223+ }
224+ if err := g.rel.DeleteRelease(ctx, p, rn, id); err != nil {
225+ return err
226+ }
227+ ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release deleted")
228+ return nil
229+}
230+func releasePagination(page, perPage int) (int, int) {
231+ if page < 1 {
232+ page = 1
233+ }
234+ if perPage < 1 {
235+ perPage = 30
236+ }
237+ return page, min(perPage, 100)
238+}
239+func releasePageBounds(page, perPage, total int) (int, int) {
240+ // Avoid overflow on arbitrary query parameters.
241+ if page-1 > total/perPage {
242+ return total, total
243+ }
244+ start := min((page-1)*perPage, total)
245+ return start, min(start+perPage, total)
246+}
247+
248+// ListReleases returns visible releases newest first and GitHub Link pagination.
249+func (g *ReleasesGitHub) ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) ([]byte, string, error) {
250+ rn, err := g.repo(owner, repo)
251+ if err != nil {
252+ return nil, "", err
253+ }
254+ if _, err = g.rel.readable(ctx, p, rn); err != nil {
255+ return nil, "", err
256+ }
257+ all, err := g.rel.store.ListReleases(ctx, rn)
258+ if err != nil {
259+ return nil, "", err
260+ }
261+ all = slices.DeleteFunc(all, func(r domain.Release) bool { return r.Draft && !releaseWriter(p, rn) })
262+ slices.SortFunc(all, func(a, b domain.Release) int {
263+ if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
264+ return c
265+ }
266+ if a.ID > b.ID {
267+ return -1
268+ }
269+ if a.ID < b.ID {
270+ return 1
271+ }
272+ return 0
273+ })
274+ page, perPage = releasePagination(page, perPage)
275+ start, end := releasePageBounds(page, perPage, len(all))
276+ objects := make([]ordered, 0, end-start)
277+ for _, r := range all[start:end] {
278+ o, e := g.releaseObj(ctx, p, r)
279+ if e != nil {
280+ return nil, "", e
281+ }
282+ objects = append(objects, o)
283+ }
284+ b, err := json.Marshal(objects)
285+ return b, pullFilesLink(g.base(rn)+"/releases", page, perPage, len(all)), err
286+}
287+
288+// ListAssets lists uploaded assets with the same pagination conventions.
289+func (g *ReleasesGitHub) ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) ([]byte, string, error) {
290+ rn, err := g.repo(owner, repo)
291+ if err != nil {
292+ return nil, "", err
293+ }
294+ r, err := g.rel.GetRelease(ctx, p, rn, ports.ReleaseRef{ID: id})
295+ if err != nil {
296+ return nil, "", err
297+ }
298+ all, err := g.rel.ListAssets(ctx, p, rn, id)
299+ if err != nil {
300+ return nil, "", err
301+ }
302+ page, perPage = releasePagination(page, perPage)
303+ start, end := releasePageBounds(page, perPage, len(all))
304+ objects := make([]ordered, 0, end-start)
305+ for _, a := range all[start:end] {
306+ objects = append(objects, g.assetObj(a, r.Tag))
307+ }
308+ b, err := json.Marshal(objects)
309+ return b, pullFilesLink(g.base(rn)+"/releases/"+strconv.FormatInt(id, 10)+"/assets", page, perPage, len(all)), err
310+}
311+func (g *ReleasesGitHub) marshalAsset(ctx context.Context, p domain.Principal, a domain.ReleaseAsset) ([]byte, error) {
312+ r, err := g.rel.GetRelease(ctx, p, a.Repo, ports.ReleaseRef{ID: a.ReleaseID})
313+ if err != nil {
314+ return nil, err
315+ }
316+ return json.Marshal(g.assetObj(a, r.Tag))
317+}
318+
319+// UploadAsset streams an exact-sized raw body; duplicate names are not replaced.
320+func (g *ReleasesGitHub) UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, body io.Reader) ([]byte, error) {
321+ rn, err := g.repo(owner, repo)
322+ if err != nil {
323+ return nil, err
324+ }
325+ a, err := g.rel.UploadAsset(ctx, p, rn, ports.UploadAssetParams{ReleaseID: id, Name: name, Label: label, ContentType: contentType, Size: size}, body)
326+ if err != nil {
327+ return nil, err
328+ }
329+ return g.marshalAsset(ctx, p, a)
330+}
331+
332+// AssetByID resolves a visible asset, including its repository ownership.
333+func (g *ReleasesGitHub) AssetByID(ctx context.Context, p domain.Principal, owner, repo string, id int64) (domain.ReleaseAsset, error) {
334+ rn, err := g.repo(owner, repo)
335+ if err != nil {
336+ return domain.ReleaseAsset{}, err
337+ }
338+ return g.rel.GetAsset(ctx, p, rn, ports.AssetRef{ID: id})
339+}
340+
341+// GetAsset returns the JSON representation, not the contents.
342+func (g *ReleasesGitHub) GetAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) ([]byte, error) {
343+ a, err := g.AssetByID(ctx, p, owner, repo, id)
344+ if err != nil {
345+ return nil, err
346+ }
347+ return g.marshalAsset(ctx, p, a)
348+}
349+
350+// UpdateAsset changes the name and label fields that are present.
351+func (g *ReleasesGitHub) UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) {
352+ rn, err := g.repo(owner, repo)
353+ if err != nil {
354+ return nil, err
355+ }
356+ f, err := releaseFields(body)
357+ if err != nil {
358+ return nil, err
359+ }
360+ name, err := releaseField[string](f, "name")
361+ if err != nil {
362+ return nil, err
363+ }
364+ label, err := releaseField[string](f, "label")
365+ if err != nil {
366+ return nil, err
367+ }
368+ a, err := g.rel.UpdateAsset(ctx, p, rn, id, ports.UpdateAssetParams{Name: name, Label: label})
369+ if err != nil {
370+ return nil, err
371+ }
372+ ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset updated")
373+ return g.marshalAsset(ctx, p, a)
374+}
375+
376+// DeleteAsset removes the asset record and its blob.
377+func (g *ReleasesGitHub) DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) error {
378+ rn, err := g.repo(owner, repo)
379+ if err != nil {
380+ return err
381+ }
382+ if err := g.rel.DeleteAsset(ctx, p, rn, id); err != nil {
383+ return err
384+ }
385+ ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset deleted")
386+ return nil
387+}
A internal/usecase/releases_github_test.go +360 -0
@@ -0,0 +1,360 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "math"
11+ "net/http"
12+ "net/http/httptest"
13+ "os"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ "testing"
18+
19+ "bnema.dev/ermite/internal/adapters/githubapi"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+ "bnema.dev/ermite/internal/usecase"
24+ "github.com/stretchr/testify/mock"
25+ "github.com/stretchr/testify/require"
26+)
27+
28+func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub {
29+ t.Helper()
30+ return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/")
31+}
32+func releaseGolden(t *testing.T, file string, body []byte) {
33+ t.Helper()
34+ var pretty bytes.Buffer
35+ require.NoError(t, json.Indent(&pretty, body, "", " "))
36+ pretty.WriteByte('\n')
37+ path := filepath.Join("..", "adapters", "githubapi", "testdata", "releases", file+".json")
38+ if os.Getenv("UPDATE_RELEASE_GOLDENS") == "1" {
39+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
40+ require.NoError(t, os.WriteFile(path, pretty.Bytes(), 0644))
41+ }
42+ want, err := os.ReadFile(path)
43+ require.NoError(t, err)
44+ require.Equal(t, string(want), pretty.String())
45+}
46+func TestGitHubReleaseGoldens(t *testing.T) {
47+ r := newReleaseRig(t, ports.ReleaseLimits{})
48+ g := releaseGitHub(t, r)
49+ rel := r.create(t, "v1", false)
50+ for _, name := range []string{"ermite.tar.gz", "checksums.txt"} {
51+ _, err := g.UploadAsset(r.ctx, domain.Root(), "acme", r.repo.String(), rel.ID, name, "", "application/octet-stream", 3, strings.NewReader("abc"))
52+ require.NoError(t, err)
53+ }
54+ body, err := g.GetRelease(r.ctx, domain.Anonymous(), "ACME", "RELEASES", ports.ReleaseRef{Tag: "v1"})
55+ require.NoError(t, err)
56+ releaseGolden(t, "release", body)
57+ draft := r.create(t, "next", true)
58+ body, err = g.GetRelease(r.ctx, domain.Root(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
59+ require.NoError(t, err)
60+ releaseGolden(t, "draft", body)
61+ _, err = g.GetRelease(r.ctx, domain.Anonymous(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
62+ require.ErrorIs(t, err, domain.ErrNotFound)
63+}
64+func TestGitHubReleaseRequests(t *testing.T) {
65+ r := newReleaseRig(t, ports.ReleaseLimits{})
66+ g := releaseGitHub(t, r)
67+ repo := r.repo.String()
68+ p := domain.Root()
69+ r.resolve(t, "v1", nil)
70+ body, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1","name":"Title","body":"Notes","draft":true,"prerelease":true,"discussion_category_name":"ignored","make_latest":"legacy"}`))
71+ require.NoError(t, err)
72+ var rel struct {
73+ ID int64 `json:"id"`
74+ }
75+ require.NoError(t, json.Unmarshal(body, &rel))
76+ body, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"name":"","draft":false,"prerelease":false,"make_latest":"true","unknown":1}`))
77+ require.NoError(t, err)
78+ var got map[string]any
79+ require.NoError(t, json.Unmarshal(body, &got))
80+ require.Equal(t, "", got["name"])
81+ require.Equal(t, "Notes", got["body"])
82+ require.Equal(t, false, got["draft"])
83+ require.Equal(t, false, got["prerelease"])
84+ require.NotContains(t, got, "make_latest")
85+ stored, err := r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
86+ require.NoError(t, err)
87+ require.True(t, stored.MakeLatest)
88+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"legacy"}`))
89+ require.NoError(t, err)
90+ stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
91+ require.NoError(t, err)
92+ require.True(t, stored.MakeLatest)
93+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"false"}`))
94+ require.NoError(t, err)
95+ stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
96+ require.NoError(t, err)
97+ require.False(t, stored.MakeLatest)
98+ for _, bad := range []string{`{}`, `null`, `[]`, `{`, `{"tag_name":2}`, `{"tag_name":"v1","draft":"true"}`, `{"tag_name":"v1","name":null}`, `{"tag_name":"v1","make_latest":true}`, `{"tag_name":"v1","make_latest":"yes"}`} {
99+ _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(bad))
100+ require.ErrorIs(t, err, domain.ErrInvalid, bad)
101+ }
102+ _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1"}`))
103+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
104+ _, err = g.GetRelease(r.ctx, p, "other", repo, ports.ReleaseRef{ID: rel.ID})
105+ require.ErrorIs(t, err, domain.ErrNotFound)
106+ _, err = g.GetRelease(r.ctx, p, "acme", "bad/name", ports.ReleaseRef{ID: rel.ID})
107+ require.ErrorIs(t, err, domain.ErrNotFound)
108+}
109+func TestGitHubReleasePagination(t *testing.T) {
110+ r := newReleaseRig(t, ports.ReleaseLimits{})
111+ g := releaseGitHub(t, r)
112+ for i := 0; i < 5; i++ {
113+ r.create(t, "v"+strconv.Itoa(i), i == 4)
114+ }
115+ for page := 1; page <= 3; page++ {
116+ b, link, err := g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
117+ require.NoError(t, err)
118+ var rows []struct {
119+ ID int64 `json:"id"`
120+ }
121+ require.NoError(t, json.Unmarshal(b, &rows))
122+ require.Len(t, rows, min(2, 5-(page-1)*2))
123+ require.Equal(t, int64(5-(page-1)*2), rows[0].ID)
124+ base := "http://localhost:7341/-/github/api/repos/acme/releases/releases?per_page=2&page="
125+ switch page {
126+ case 1:
127+ require.Equal(t, `<`+base+`2>; rel="next", <`+base+`3>; rel="last"`, link)
128+ case 2:
129+ require.Equal(t, `<`+base+`3>; rel="next", <`+base+`3>; rel="last", <`+base+`1>; rel="first", <`+base+`1>; rel="prev"`, link)
130+ case 3:
131+ require.Equal(t, `<`+base+`1>; rel="first", <`+base+`2>; rel="prev"`, link)
132+ }
133+ }
134+ b, _, err := g.ListReleases(r.ctx, domain.Anonymous(), "acme", r.repo.String(), 0, 0)
135+ require.NoError(t, err)
136+ var rows []any
137+ require.NoError(t, json.Unmarshal(b, &rows))
138+ require.Len(t, rows, 4)
139+ for _, page := range []int{4, math.MaxInt} {
140+ b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
141+ require.NoError(t, err)
142+ require.Equal(t, "[]", string(b))
143+ }
144+ b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), 1, 1000)
145+ require.NoError(t, err)
146+ require.NoError(t, json.Unmarshal(b, &rows))
147+ require.Len(t, rows, 5)
148+}
149+func TestGitHubReleaseAssetsAndRetry(t *testing.T) {
150+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10})
151+ g := releaseGitHub(t, r)
152+ p := domain.Root()
153+ repo := r.repo.String()
154+ rel := r.create(t, "v1/slash", false)
155+ upload := func(name string) ([]byte, error) {
156+ return g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, name, "label", "text/plain", 3, strings.NewReader("abc"))
157+ }
158+ b, err := upload("é #?.txt")
159+ require.NoError(t, err)
160+ var asset struct {
161+ ID int64 `json:"id"`
162+ URL string `json:"browser_download_url"`
163+ }
164+ require.NoError(t, json.Unmarshal(b, &asset))
165+ require.Equal(t, "http://localhost:7341/releases/releases/download/v1%2Fslash/%C3%A9%20%23%3F.txt", asset.URL)
166+ _, err = upload("é #?.txt")
167+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
168+ b, err = g.UpdateAsset(r.ctx, p, "acme", repo, asset.ID, []byte(`{"label":""}`))
169+ require.NoError(t, err)
170+ require.Contains(t, string(b), `"label":""`)
171+ require.Contains(t, string(b), `"name":"é #?.txt"`)
172+ b, err = g.GetAsset(r.ctx, domain.Anonymous(), "acme", repo, asset.ID)
173+ require.NoError(t, err)
174+ require.Contains(t, string(b), `"state":"uploaded"`)
175+ for i := 0; i < 2; i++ {
176+ _, err = upload(fmt.Sprint(i))
177+ require.NoError(t, err)
178+ }
179+ b, link, err := g.ListAssets(r.ctx, p, "acme", repo, rel.ID, 1, 2)
180+ require.NoError(t, err)
181+ require.Contains(t, link, `rel="next"`)
182+ var assets []any
183+ require.NoError(t, json.Unmarshal(b, &assets))
184+ require.Len(t, assets, 2)
185+ require.NoError(t, g.DeleteAsset(r.ctx, p, "acme", repo, asset.ID))
186+ _, err = g.AssetByID(r.ctx, p, "acme", repo, asset.ID)
187+ require.ErrorIs(t, err, domain.ErrNotFound)
188+ _, err = upload("é #?.txt")
189+ require.NoError(t, err)
190+ _, err = g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, "huge", "", "", 11, strings.NewReader("ignored"))
191+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
192+ require.NoError(t, g.DeleteRelease(r.ctx, p, "acme", repo, rel.ID))
193+ _, err = g.GetRelease(r.ctx, p, "acme", repo, ports.ReleaseRef{ID: rel.ID})
194+ require.ErrorIs(t, err, domain.ErrNotFound)
195+}
196+
197+func TestGitHubReleaseAuthorizationAndLatest(t *testing.T) {
198+ r := newReleaseRig(t, ports.ReleaseLimits{})
199+ g := releaseGitHub(t, r)
200+ repo := r.repo.String()
201+ stable := r.create(t, "v1", false)
202+ r.create(t, "draft", true)
203+ b, err := g.GetRelease(r.ctx, domain.Anonymous(), "acme", repo, ports.ReleaseRef{Latest: true})
204+ require.NoError(t, err)
205+ var got struct {
206+ ID int64 `json:"id"`
207+ }
208+ require.NoError(t, json.Unmarshal(b, &got))
209+ require.Equal(t, stable.ID, got.ID)
210+ writer := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionWrite}})
211+ reader := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
212+ branched := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, BranchPrefixes: []string{"feature/"}})
213+ for _, tc := range []struct {
214+ p domain.Principal
215+ tag string
216+ want error
217+ }{
218+ {domain.Anonymous(), "anonymous", domain.ErrUnauthenticated},
219+ {reader, "reader", domain.ErrForbidden},
220+ {branched, "branch", domain.ErrForbidden},
221+ {writer, "job", nil},
222+ } {
223+ if tc.want == nil {
224+ r.resolve(t, tc.tag, nil)
225+ }
226+ b, err = g.CreateRelease(r.ctx, tc.p, "acme", repo, []byte(`{"tag_name":"`+tc.tag+`"}`))
227+ if tc.want != nil {
228+ require.ErrorIs(t, err, tc.want)
229+ continue
230+ }
231+ require.NoError(t, err)
232+ require.NoError(t, json.Unmarshal(b, &got))
233+ _, err = g.UploadAsset(r.ctx, tc.p, "acme", repo, got.ID, "ci.tar.gz", "", "", 3, strings.NewReader("abc"))
234+ require.NoError(t, err)
235+ _, err = g.UploadAsset(r.ctx, reader, "acme", repo, got.ID, "read.tar.gz", "", "", 3, strings.NewReader("abc"))
236+ require.ErrorIs(t, err, domain.ErrForbidden)
237+ }
238+ other, err := domain.ParseRepoName("other")
239+ require.NoError(t, err)
240+ require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
241+ b, _, err = g.ListAssets(r.ctx, domain.Root(), "acme", other.String(), stable.ID, 1, 30)
242+ require.ErrorIs(t, err, domain.ErrNotFound)
243+ require.Nil(t, b)
244+}
245+
246+func TestGitHubReleaseTargetAndMissingTag(t *testing.T) {
247+ r := newReleaseRig(t, ports.ReleaseLimits{})
248+ g := releaseGitHub(t, r)
249+ p := domain.Root()
250+ repo := r.repo.String()
251+ r.resolve(t, "missing", domain.ErrNotFound)
252+ _, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing"}`))
253+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
254+ b, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing","draft":true}`))
255+ require.NoError(t, err)
256+ var got struct {
257+ ID int64 `json:"id"`
258+ Target string `json:"target_commitish"`
259+ }
260+ require.NoError(t, json.Unmarshal(b, &got))
261+ require.Empty(t, got.Target)
262+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false}`))
263+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
264+ r.resolve(t, "new", domain.ErrNotFound)
265+ v, err := domain.ParseRevision("main")
266+ require.NoError(t, err)
267+ oid, err := domain.ParseObjectID(oidA)
268+ require.NoError(t, err)
269+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
270+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
271+ r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
272+ b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"new","target_commitish":"main"}`))
273+ require.NoError(t, err)
274+ require.NoError(t, json.Unmarshal(b, &got))
275+ require.Equal(t, oidA, got.Target)
276+
277+ // PATCH with target_commitish publishes a draft whose tag is missing.
278+ r.resolve(t, "patched", domain.ErrNotFound)
279+ b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"patched","draft":true}`))
280+ require.NoError(t, err)
281+ require.NoError(t, json.Unmarshal(b, &got))
282+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
283+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "patched", oid).Return(nil).Once()
284+ r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/patched", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
285+ b, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false,"target_commitish":"main"}`))
286+ require.NoError(t, err)
287+ require.Contains(t, string(b), `"draft":false`)
288+ require.Contains(t, string(b), `"target_commitish":"`+oidA+`"`)
289+}
290+
291+// A real HTTP round trip through the facade, metadata store and disk blob
292+// adapter protects the goreleaser/softprops create, retry, overwrite, publish
293+// sequence, not just each side's mocked contract.
294+func TestGitHubReleaseHTTPWorkflow(t *testing.T) {
295+ r := newReleaseRig(t, ports.ReleaseLimits{})
296+ g := releaseGitHub(t, r)
297+ auth := mocks.NewAuthService(t)
298+ auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe()
299+ srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc}))
300+ defer srv.Close()
301+ do := func(method, path, body string, headers map[string]string) (*http.Response, string) {
302+ req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body))
303+ require.NoError(t, err)
304+ req.Header.Set("Authorization", "Bearer dev-token")
305+ for k, v := range headers {
306+ req.Header.Set(k, v)
307+ }
308+ resp, err := srv.Client().Do(req)
309+ require.NoError(t, err)
310+ defer func() { _ = resp.Body.Close() }()
311+ b, err := io.ReadAll(resp.Body)
312+ require.NoError(t, err)
313+ return resp, string(b)
314+ }
315+ base := "/repos/acme/" + r.repo.String() + "/releases"
316+ resp, _ := do("GET", base+"/tags/v1", "", nil)
317+ require.Equal(t, 404, resp.StatusCode)
318+ r.resolve(t, "v1", nil)
319+ resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil)
320+ require.Equal(t, 201, resp.StatusCode)
321+ var rel struct {
322+ ID int64 `json:"id"`
323+ UploadURL string `json:"upload_url"`
324+ }
325+ require.NoError(t, json.Unmarshal([]byte(b), &rel))
326+ upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz"
327+ resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"})
328+ require.Equal(t, 201, resp.StatusCode)
329+ var a struct {
330+ ID int64 `json:"id"`
331+ }
332+ require.NoError(t, json.Unmarshal([]byte(b), &a))
333+ resp, b = do("POST", upload, "replacement", nil)
334+ require.Equal(t, 422, resp.StatusCode)
335+ require.Contains(t, b, `"code":"already_exists"`)
336+ resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil)
337+ require.Equal(t, 200, resp.StatusCode)
338+ require.Contains(t, b, `"name":"archive.tar.gz"`)
339+ assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10)
340+ resp, _ = do("DELETE", assetURL, "", nil)
341+ require.Equal(t, 204, resp.StatusCode)
342+ resp, b = do("POST", upload, "abcdefghij", nil)
343+ require.Equal(t, 201, resp.StatusCode)
344+ require.NoError(t, json.Unmarshal([]byte(b), &a))
345+ resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil)
346+ require.Equal(t, 200, resp.StatusCode)
347+ assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10)
348+ resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"})
349+ require.Equal(t, 206, resp.StatusCode)
350+ require.Equal(t, "cdef", b)
351+ require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range"))
352+ require.Equal(t, "4", resp.Header.Get("Content-Length"))
353+ require.Empty(t, resp.Header.Get("Location"))
354+ resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"})
355+ require.Equal(t, 200, resp.StatusCode)
356+ require.Empty(t, b)
357+ require.Equal(t, "10", resp.Header.Get("Content-Length"))
358+ resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")})
359+ require.Equal(t, 304, resp.StatusCode)
360+}
A internal/usecase/releases_integration_test.go +336 -0
@@ -0,0 +1,336 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "testing"
9+ "testing/synctest"
10+
11+ "bnema.dev/ermite/internal/adapters/blobdisk"
12+ "bnema.dev/ermite/internal/adapters/eventbus"
13+ "bnema.dev/ermite/internal/adapters/kvstore"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+type releaseRig struct {
23+ ctx context.Context
24+ repo domain.RepoName
25+ store *kvstore.Store
26+ blobs ports.BlobStore
27+ reader *mocks.RepoReader
28+ tags *mocks.ReleaseTagWriter
29+ pushes *mocks.PushHookService
30+ clock *mocks.Clock
31+ events <-chan domain.Event
32+ svc *usecase.Releases
33+}
34+
35+func newReleaseRig(t *testing.T, limits ports.ReleaseLimits) *releaseRig {
36+ t.Helper()
37+ ctx := t.Context()
38+ store, err := kvstore.Open(ctx, t.TempDir())
39+ require.NoError(t, err)
40+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
41+ n := name(t, "releases")
42+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now}))
43+ clock := mocks.NewClock(t)
44+ clock.EXPECT().Now().Return(now).Maybe()
45+ reader := mocks.NewRepoReader(t)
46+ tags := mocks.NewReleaseTagWriter(t)
47+ pushes := mocks.NewPushHookService(t)
48+ bus := eventbus.NewWithBuffer(1024)
49+ b := blobdisk.New(t.TempDir())
50+ r := &releaseRig{ctx: ctx, repo: n, store: store, blobs: b, reader: reader, tags: tags, pushes: pushes, clock: clock, events: bus.Subscribe(ctx)}
51+ r.svc = usecase.NewReleases(store.Releases(), b, store, reader, tags, pushes, bus, clock, limits)
52+ return r
53+}
54+func (r *releaseRig) resolve(t *testing.T, tag string, err error) {
55+ t.Helper()
56+ v, e := domain.ParseRevision("refs/tags/" + tag)
57+ require.NoError(t, e)
58+ oid, e := domain.ParseObjectID(oidA)
59+ require.NoError(t, e)
60+ r.reader.EXPECT().ResolveRevision(mock.Anything, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, err).Maybe()
61+}
62+func (r *releaseRig) create(t *testing.T, tag string, draft bool) domain.Release {
63+ t.Helper()
64+ r.resolve(t, tag, nil)
65+ rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: tag, Draft: draft})
66+ require.NoError(t, err)
67+ return rel
68+}
69+func TestReleaseAccessMatrix(t *testing.T) {
70+ for _, private := range []bool{false, true} {
71+ t.Run(fmt.Sprint("private=", private), func(t *testing.T) {
72+ r := newReleaseRig(t, ports.ReleaseLimits{})
73+ published := r.create(t, "v1", false)
74+ draft := r.create(t, "draft", true)
75+ if private {
76+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Visibility = domain.VisibilityPrivate; return true, nil })
77+ require.NoError(t, err)
78+ }
79+ token := func(scopes []domain.Scope, branches ...string) domain.Principal {
80+ return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, BranchPrefixes: branches})
81+ }
82+ for _, tc := range []struct {
83+ name string
84+ p domain.Principal
85+ read, write bool
86+ }{
87+ {"anonymous", domain.Anonymous(), !private, false},
88+ {"reader", token([]domain.Scope{domain.ScopeRepoRead}), true, false},
89+ {"writer", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}), true, true},
90+ {"write-only", token([]domain.Scope{domain.ScopeReleaseWrite}), !private, !private},
91+ {"branch", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, "x/"), true, false},
92+ {"job-read", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "read"}}), true, false},
93+ {"job-write", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "write"}}), true, true},
94+ {"owner", domain.Owner(), true, true},
95+ } {
96+ t.Run(tc.name, func(t *testing.T) {
97+ _, err := r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: published.ID})
98+ if tc.read {
99+ require.NoError(t, err)
100+ } else {
101+ require.ErrorIs(t, err, domain.ErrNotFound)
102+ }
103+ _, err = r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: draft.ID})
104+ if tc.write {
105+ require.NoError(t, err)
106+ } else {
107+ require.ErrorIs(t, err, domain.ErrNotFound)
108+ }
109+ title := "changed"
110+ _, err = r.svc.UpdateRelease(r.ctx, tc.p, r.repo, published.ID, ports.UpdateReleaseParams{Title: &title})
111+ switch {
112+ case tc.write:
113+ require.NoError(t, err)
114+ case !tc.read:
115+ require.ErrorIs(t, err, domain.ErrNotFound)
116+ case tc.p.IsAnonymous():
117+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
118+ default:
119+ require.ErrorIs(t, err, domain.ErrForbidden)
120+ }
121+ })
122+ }
123+ })
124+ }
125+}
126+func TestReleaseLifecycle(t *testing.T) {
127+ r := newReleaseRig(t, ports.ReleaseLimits{})
128+ first := r.create(t, "v1", false)
129+ second := r.create(t, "v2", false)
130+ r.create(t, "draft", true)
131+ got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
132+ require.NoError(t, err)
133+ require.Equal(t, second.ID, got.ID)
134+ yes := true
135+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
136+ require.NoError(t, err)
137+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
138+ require.NoError(t, err)
139+ require.Equal(t, first.ID, got.ID)
140+ _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "v1"})
141+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
142+ page, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, "")
143+ require.NoError(t, err)
144+ require.Len(t, page.Releases, 1)
145+ require.NotEmpty(t, page.NextPageToken)
146+ next, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, page.NextPageToken)
147+ require.NoError(t, err)
148+ require.Equal(t, first.ID, next.Releases[0].ID)
149+ require.Empty(t, next.NextPageToken)
150+ r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: domain.ZeroOID}})
151+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
152+ require.NoError(t, err)
153+ require.True(t, got.Draft)
154+ require.True(t, got.PublishedAt.IsZero())
155+ require.False(t, got.MakeLatest)
156+ r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: oidA}})
157+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
158+ require.NoError(t, err)
159+ require.True(t, got.Draft)
160+ var kinds []string
161+ for len(r.events) > 0 {
162+ kinds = append(kinds, (<-r.events).EventKind())
163+ }
164+ require.Contains(t, kinds, "release.unpublished")
165+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
166+ _, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
167+ require.ErrorIs(t, err, domain.ErrNotFound)
168+}
169+func TestReleaseMissingTag(t *testing.T) {
170+ r := newReleaseRig(t, ports.ReleaseLimits{})
171+ r.resolve(t, "missing", domain.ErrNotFound)
172+ draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
173+ require.NoError(t, err)
174+ require.Empty(t, draft.TargetCommitish)
175+ no := false
176+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
177+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
178+ r.resolve(t, "new", domain.ErrNotFound)
179+ _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new"})
180+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
181+ v, err := domain.ParseRevision("main")
182+ require.NoError(t, err)
183+ oid, err := domain.ParseObjectID(oidA)
184+ require.NoError(t, err)
185+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
186+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
187+ r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
188+ rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new", TargetCommitish: "main"})
189+ require.NoError(t, err)
190+ require.Equal(t, oidA, rel.TargetCommitish)
191+}
192+
193+func TestReleaseLatestPinsAndPrereleases(t *testing.T) {
194+ r := newReleaseRig(t, ports.ReleaseLimits{})
195+ a := r.create(t, "a", false)
196+ b := r.create(t, "b", false)
197+ yes := true
198+ _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateReleaseParams{Prerelease: &yes})
199+ require.NoError(t, err)
200+ latest, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
201+ require.NoError(t, err)
202+ require.Equal(t, a.ID, latest.ID)
203+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, a.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
204+ require.NoError(t, err)
205+ r.resolve(t, "c", nil)
206+ c, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "c", MakeLatest: true})
207+ require.NoError(t, err)
208+ a, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: a.ID})
209+ require.NoError(t, err)
210+ require.False(t, a.MakeLatest)
211+ target := "b"
212+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, c.ID, ports.UpdateReleaseParams{Tag: &target})
213+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
214+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, c.ID))
215+ latest, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
216+ require.NoError(t, err)
217+ require.Equal(t, a.ID, latest.ID)
218+}
219+
220+func TestReleaseEventsReachHomeAndRepositoryTopics(t *testing.T) {
221+ synctest.Test(t, func(t *testing.T) {
222+ r := newEventRig(t, 0)
223+ pub := r.repo("pub", domain.VisibilityPublic)
224+ priv := r.repo("priv", domain.VisibilityPrivate)
225+ ctx, cancel := context.WithCancel(context.Background())
226+ defer cancel()
227+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
228+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
229+ synctest.Wait()
230+ for _, n := range []domain.RepoName{priv, pub} {
231+ vis := domain.VisibilityPublic
232+ if n == priv {
233+ vis = domain.VisibilityPrivate
234+ }
235+ rel := domain.Release{ID: 1, Repo: n, Tag: "v1"}
236+ r.feed <- domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
237+ r.feed <- domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: vis}
238+ r.feed <- domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: vis}
239+ r.feed <- domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
240+ }
241+ for _, w := range []*watcher{anon, owner} {
242+ for _, kind := range []string{"release.published", "release.updated", "release.deleted", "release.unpublished"} {
243+ require.Equal(t, kind, w.next(t).Event.EventKind())
244+ }
245+ }
246+ anon.quiet(t, r, pub)
247+ owner.quiet(t, r, priv)
248+ })
249+}
250+
251+func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
252+ ctx := t.Context()
253+ repo := name(t, "retry")
254+ store := mocks.NewReleaseStore(t)
255+ meta := mocks.NewRepoMetaStore(t)
256+ reader := mocks.NewRepoReader(t)
257+ clock := mocks.NewClock(t)
258+ bus := mocks.NewEventBus(t)
259+ meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil)
260+ clock.EXPECT().Now().Return(now).Maybe()
261+ rev, err := domain.ParseRevision("refs/tags/v1")
262+ require.NoError(t, err)
263+ reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264+ store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265+ store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267+ store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
269+ store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 2 && len(w.PutReleases) == 1 })).Return(nil).Once()
270+ store.EXPECT().GetRelease(ctx, repo, int64(1)).Return(domain.Release{ID: 1, Repo: repo, Tag: "v1", Draft: true}, nil)
271+ svc := usecase.NewReleases(store, nil, meta, reader, nil, nil, bus, clock, ports.ReleaseLimits{})
272+ rel, err := svc.CreateRelease(ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v1", Draft: true})
273+ require.NoError(t, err)
274+ require.EqualValues(t, 1, rel.ID)
275+}
276+
277+func TestReleaseListHidesDraftCursors(t *testing.T) {
278+ r := newReleaseRig(t, ports.ReleaseLimits{})
279+ pub := r.create(t, "v1", false)
280+ d1 := r.create(t, "d1", true)
281+ d2 := r.create(t, "d2", true)
282+ anon := domain.Anonymous()
283+
284+ _, errDraft := r.svc.ListReleases(r.ctx, anon, r.repo, 1, fmt.Sprint(d1.ID))
285+ _, errUnknown := r.svc.ListReleases(r.ctx, anon, r.repo, 1, "999999")
286+ require.ErrorIs(t, errUnknown, domain.ErrInvalidPageToken)
287+ require.Equal(t, errUnknown, errDraft)
288+
289+ // A writer still pages through drafts, newest first.
290+ var got []int64
291+ token := ""
292+ for {
293+ page, err := r.svc.ListReleases(r.ctx, domain.Root(), r.repo, 1, token)
294+ require.NoError(t, err)
295+ for _, rel := range page.Releases {
296+ got = append(got, rel.ID)
297+ }
298+ if page.NextPageToken == "" {
299+ break
300+ }
301+ token = page.NextPageToken
302+ }
303+ require.ElementsMatch(t, []int64{pub.ID, d1.ID, d2.ID}, got)
304+}
305+
306+func TestReleaseUpdateTargetCreatesMissingTag(t *testing.T) {
307+ r := newReleaseRig(t, ports.ReleaseLimits{})
308+ r.resolve(t, "missing", domain.ErrNotFound)
309+ draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
310+ require.NoError(t, err)
311+ no := false
312+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
313+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
314+
315+ v, err := domain.ParseRevision("main")
316+ require.NoError(t, err)
317+ oid, err := domain.ParseObjectID(oidA)
318+ require.NoError(t, err)
319+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
320+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "missing", oid).Return(nil).Once()
321+ r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/missing", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
322+ main := "main"
323+ rel, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no, TargetCommitish: &main})
324+ require.NoError(t, err)
325+ require.False(t, rel.Draft)
326+ require.Equal(t, oidA, rel.TargetCommitish)
327+}
328+
329+func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) {
330+ r := newReleaseRig(t, ports.ReleaseLimits{})
331+ rel := r.create(t, "v1", false)
332+ other := "other" // never resolved: the mock would fail the test
333+ got, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, rel.ID, ports.UpdateReleaseParams{TargetCommitish: &other})
334+ require.NoError(t, err)
335+ require.Equal(t, oidA, got.TargetCommitish)
336+}
A internal/usecase/releases_sweep.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// Sweep collects abandoned uploads, missing contents, and old orphan blobs.
16+// A failed metadata scan never authorizes blob deletion. Sweeps are serialized
17+// so the two observations of a missing blob are always distinct sweeps.
18+func (r *Releases) Sweep(ctx context.Context) {
19+ r.sweepMu.Lock()
20+ defer r.sweepMu.Unlock()
21+ log := ctxLog(ctx)
22+ now := r.clock.Now()
23+ all, err := r.store.AllAssets(ctx)
24+ if err != nil {
25+ log.Error().Err(err).Msg("scan release assets")
26+ return
27+ }
28+ seen := make(map[int64]bool, len(all))
29+ for _, a := range all {
30+ seen[a.ID] = true
31+ remove := false
32+ switch a.State {
33+ case domain.ReleaseAssetUploading:
34+ remove = now.Sub(a.UpdatedAt) > domain.ReleaseAssetPendingMax
35+ case domain.ReleaseAssetUploaded:
36+ _, err := r.blobs.Stat(ctx, a.BlobKey())
37+ switch {
38+ case errors.Is(err, domain.ErrNotFound):
39+ r.misses[a.ID]++
40+ remove = r.misses[a.ID] >= 2
41+ if !remove {
42+ log.Error().Int64("asset", a.ID).Msg("asset blob missing")
43+ }
44+ case err != nil:
45+ log.Error().Err(err).Int64("asset", a.ID).Msg("stat release asset")
46+ default:
47+ delete(r.misses, a.ID)
48+ }
49+ }
50+ if !remove {
51+ continue
52+ }
53+ err := r.removeAssetRecord(ctx, a, func(current domain.ReleaseAsset) bool { return current.Version == a.Version })
54+ if err != nil {
55+ if !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, domain.ErrConflict) {
56+ log.Error().Err(err).Int64("asset", a.ID).Msg("remove stale release asset")
57+ }
58+ continue
59+ }
60+ delete(r.misses, a.ID)
61+ r.removeAssetBlobs(ctx, []domain.ReleaseAsset{a})
62+ }
63+ for id := range r.misses {
64+ if !seen[id] {
65+ delete(r.misses, id)
66+ }
67+ }
68+ // Re-check the store for each old key: concurrent reservations must not be
69+ // classified as orphans from a stale snapshot.
70+ err = r.blobs.List(ctx, "release/", func(info ports.BlobInfo) error {
71+ if now.Sub(info.ModTime) <= domain.ReleaseAssetPendingMax {
72+ return nil
73+ }
74+ // Only a canonical key can name an asset. Check its record directly
75+ // rather than repeating the full metadata scan for every object.
76+ id, parseErr := strconv.ParseInt(strings.TrimPrefix(info.Key, "release/"), 10, 64)
77+ if parseErr == nil && (domain.ReleaseAsset{ID: id}).BlobKey() == info.Key {
78+ _, e := r.store.GetAsset(ctx, id)
79+ if e == nil {
80+ return nil
81+ }
82+ if !errors.Is(e, domain.ErrNotFound) {
83+ log.Error().Err(e).Msg("check orphan release blob")
84+ return nil
85+ }
86+ }
87+ if e := r.blobs.Delete(ctx, info.Key); e != nil {
88+ log.Error().Err(e).Str("key", info.Key).Msg("remove orphan release blob")
89+ }
90+ return nil
91+ })
92+ if err != nil {
93+ log.Error().Err(err).Msg("list release blobs")
94+ }
95+ err = r.blobs.ListMultipart(ctx, "release/", func(info ports.MultipartInfo) error {
96+ if now.Sub(info.Started) > domain.ReleaseAssetPendingMax {
97+ if e := r.blobs.AbortMultipart(ctx, info.Key, info.ID); e != nil {
98+ log.Error().Err(e).Msg("abort abandoned release multipart")
99+ }
100+ }
101+ return nil
102+ })
103+ if err != nil {
104+ log.Error().Err(err).Msg("list release multipart uploads")
105+ }
106+}
A internal/usecase/releases_sweep_test.go +147 -0
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+ "bnema.dev/ermite/internal/usecase"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func (r *releaseRig) setClock(t *testing.T, at time.Time, blobs ports.BlobStore) {
21+ t.Helper()
22+ clock := mocks.NewClock(t)
23+ clock.EXPECT().Now().Return(at).Maybe()
24+ bus := mocks.NewEventBus(t)
25+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
26+ r.clock = clock
27+ r.svc = usecase.NewReleases(r.store.Releases(), blobs, r.store, r.reader, r.tags, r.pushes, bus, clock, ports.ReleaseLimits{})
28+}
29+func TestReleaseSweepPendingAndOrphans(t *testing.T) {
30+ r := newReleaseRig(t, ports.ReleaseLimits{})
31+ rel := r.create(t, "v1", false)
32+ pending := r.upload(t, rel, "pending", []byte("abc"))
33+ good := r.upload(t, rel, "good", []byte("xyz"))
34+ fresh := r.upload(t, rel, "fresh", nil)
35+ old, err := r.store.Releases().GetAsset(r.ctx, pending.ID)
36+ require.NoError(t, err)
37+ old.State = domain.ReleaseAssetUploading
38+ mp, err := r.blobs.NewMultipart(r.ctx, old.BlobKey())
39+ require.NoError(t, err)
40+ old.MultipartID = mp.ID()
41+ fresh.State = domain.ReleaseAssetUploading
42+ fresh.UpdatedAt = time.Now().Add(25 * time.Hour)
43+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
44+ require.NoError(t, err)
45+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{old, fresh}}))
46+ _, err = r.blobs.Put(r.ctx, "release/99999999999999999999", bytes.NewReader([]byte("orphan")), 6)
47+ require.NoError(t, err)
48+ // The clock is advanced beyond the disk adapter's real modification times.
49+ at := time.Now().Add(26 * time.Hour)
50+ r.setClock(t, at, r.blobs)
51+ r.svc.Sweep(r.ctx)
52+ _, err = r.store.Releases().GetAsset(r.ctx, pending.ID)
53+ require.ErrorIs(t, err, domain.ErrNotFound)
54+ _, err = r.blobs.Stat(r.ctx, pending.BlobKey())
55+ require.ErrorIs(t, err, domain.ErrNotFound)
56+ _, err = r.blobs.Stat(r.ctx, "release/99999999999999999999")
57+ require.ErrorIs(t, err, domain.ErrNotFound)
58+ _, err = r.store.Releases().GetAsset(r.ctx, good.ID)
59+ require.NoError(t, err)
60+ // The pending record inside the grace period is retained.
61+ _, err = r.store.Releases().GetAsset(r.ctx, fresh.ID)
62+ require.NoError(t, err)
63+ require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart survived"); return nil }))
64+}
65+func TestReleaseSweepMissingBlobTwoObservations(t *testing.T) {
66+ r := newReleaseRig(t, ports.ReleaseLimits{})
67+ rel := r.create(t, "v1", false)
68+ a := r.upload(t, rel, "binary", []byte("abc"))
69+ require.NoError(t, r.blobs.Delete(r.ctx, a.BlobKey()))
70+ r.svc.Sweep(r.ctx)
71+ _, err := r.store.Releases().GetAsset(r.ctx, a.ID)
72+ require.NoError(t, err)
73+ r.svc.Sweep(r.ctx)
74+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
75+ require.ErrorIs(t, err, domain.ErrNotFound)
76+ usage, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
77+ require.NoError(t, err)
78+ require.Zero(t, usage.Used)
79+}
80+
81+type releaseStatOverride struct {
82+ ports.BlobStore
83+ err error
84+}
85+
86+func (b *releaseStatOverride) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
87+ if b.err != nil {
88+ return ports.BlobInfo{}, b.err
89+ }
90+ return b.BlobStore.Stat(ctx, key)
91+}
92+func TestReleaseSweepTransientStatAndRecovery(t *testing.T) {
93+ for _, recover := range []bool{false, true} {
94+ t.Run(map[bool]string{false: "transient preserves miss", true: "success resets miss"}[recover], func(t *testing.T) {
95+ r := newReleaseRig(t, ports.ReleaseLimits{})
96+ rel := r.create(t, "v1", false)
97+ a := r.upload(t, rel, "binary", []byte("abc"))
98+ b := &releaseStatOverride{BlobStore: r.blobs, err: domain.ErrNotFound}
99+ r.setClock(t, now, b)
100+ r.svc.Sweep(r.ctx)
101+ b.err = errors.New("outage")
102+ r.svc.Sweep(r.ctx)
103+ _, err := r.store.Releases().GetAsset(r.ctx, a.ID)
104+ require.NoError(t, err)
105+ if recover {
106+ b.err = nil
107+ r.svc.Sweep(r.ctx)
108+ }
109+ b.err = domain.ErrNotFound
110+ r.svc.Sweep(r.ctx)
111+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
112+ if recover {
113+ require.NoError(t, err)
114+ } else {
115+ require.ErrorIs(t, err, domain.ErrNotFound)
116+ }
117+ })
118+ }
119+}
120+func TestReleaseSweepGraceAndFailureIsolation(t *testing.T) {
121+ r := newReleaseRig(t, ports.ReleaseLimits{})
122+ rel := r.create(t, "v1", false)
123+ a := r.upload(t, rel, "pending", nil)
124+ a.State = domain.ReleaseAssetUploading
125+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
126+ require.NoError(t, err)
127+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{a}}))
128+ b := mocks.NewBlobStore(t)
129+ b.EXPECT().List(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.BlobInfo) error) error {
130+ require.NoError(t, fn(ports.BlobInfo{Key: "release/old", ModTime: now.Add(-25 * time.Hour)}))
131+ require.NoError(t, fn(ports.BlobInfo{Key: "release/new", ModTime: now.Add(23 * time.Hour)}))
132+ return nil
133+ }).Once()
134+ b.EXPECT().Delete(r.ctx, "release/old").Return(errors.New("failed deletion")).Once()
135+ b.EXPECT().ListMultipart(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.MultipartInfo) error) error {
136+ require.NoError(t, fn(ports.MultipartInfo{Key: "release/old", ID: "old", Started: now.Add(-25 * time.Hour)}))
137+ require.NoError(t, fn(ports.MultipartInfo{Key: "release/new", ID: "new", Started: now}))
138+ return nil
139+ }).Once()
140+ b.EXPECT().AbortMultipart(r.ctx, "release/old", "old").Return(nil).Once()
141+ r.setClock(t, now.Add(24*time.Hour), b)
142+ // Use times relative to the sweep, keeping both new objects within grace.
143+ // pending exactly 24h old survives; failures in orphan deletion do not stop multipart GC.
144+ r.svc.Sweep(r.ctx)
145+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
146+ require.NoError(t, err)
147+}
A internal/usecase/releases_test.go +57 -0
@@ -0,0 +1,57 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestReleaseTagDeletionHook(t *testing.T) {
16+ ctx := context.Background()
17+ n := name(t, "tools")
18+ f := newPushFixture(t)
19+ trigger := mocks.NewReleaseTagTrigger(t)
20+ f.pushes.WithReleases(trigger)
21+ updates := []domain.RefUpdate{{Old: oidA, New: domain.ZeroOID, Ref: "refs/tags/v1"}}
22+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
23+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
24+ trigger.EXPECT().OnRefsMoved(ctx, n, updates).Return().Once()
25+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
26+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
27+ require.NoError(t, f.pushes.Record(ctx, domain.Root(), n, updates))
28+}
29+
30+func TestReleaseRepositoryDeletionHook(t *testing.T) {
31+ for _, failed := range []bool{false, true} {
32+ t.Run(map[bool]string{false: "deleted", true: "metadata failure"}[failed], func(t *testing.T) {
33+ ctx := context.Background()
34+ n := name(t, "tools")
35+ f := newFixture(t)
36+ cleaner := mocks.NewReleaseRepoCleaner(t)
37+ f.repos.WithReleases(cleaner)
38+ assets := []domain.ReleaseAsset{{ID: 42, Repo: n, ReleaseID: 1}}
39+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
40+ f.git.EXPECT().Trash(ctx, n).Return(nil)
41+ cleaner.EXPECT().AssetsOf(ctx, n).Return(assets, nil).Once()
42+ if failed {
43+ f.meta.EXPECT().Delete(ctx, n).Return(domain.ErrUnavailable).Once()
44+ require.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), domain.ErrUnavailable)
45+ } else {
46+ deleted := false
47+ f.meta.EXPECT().Delete(ctx, n).Run(func(context.Context, domain.RepoName) { deleted = true }).Return(nil).Once()
48+ cleaner.EXPECT().OnRepoDeleted(mock.Anything, n, assets).Run(func(ctx context.Context, _ domain.RepoName, _ []domain.ReleaseAsset) {
49+ require.True(t, deleted)
50+ require.Nil(t, ctx.Done())
51+ }).Return().Once()
52+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
53+ require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
54+ }
55+ })
56+ }
57+}
M internal/usecase/repos.go +9 -1
@@ -24,7 +24,8 @@ type Repos struct {
2424 ci ports.CIRunCleaner // nil: no CI logs to remove
2525 autoMerge ports.AutoMergeTrigger
2626 mergeGate ports.MergeGate
27- mirror ports.MirrorTrigger // nil: no outbound mirror
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
28+ releases ports.ReleaseRepoCleaner // nil: no release blobs to remove
2829 }
2930
3031 var _ ports.RepoService = (*Repos)(nil)
@@ -42,6 +43,13 @@ func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
4243 return r
4344 }
4445
46+// WithReleases makes Delete remove release blobs after metadata deletion.
47+// Call it before the first request; it returns r.
48+func (r *Repos) WithReleases(c ports.ReleaseRepoCleaner) *Repos {
49+ r.releases = c
50+ return r
51+}
52+
4553 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
4654 // before the first request; it returns r.
4755 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
M internal/usecase/repos_write.go +11 -0
@@ -226,9 +226,20 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
226226 log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
227227 }
228228 }
229+ var assets []domain.ReleaseAsset
230+ if r.releases != nil {
231+ var err error
232+ assets, err = r.releases.AssetsOf(ctx, name)
233+ if err != nil {
234+ log.Warn().Err(err).Str("repo", name.String()).Msg("list release assets; orphan sweep will remove their blobs")
235+ }
236+ }
229237 if err := r.meta.Delete(ctx, name); err != nil {
230238 return fmt.Errorf("delete metadata of %q: %w", name, err)
231239 }
240+ if r.releases != nil {
241+ r.releases.OnRepoDeleted(context.WithoutCancel(ctx), name, assets)
242+ }
232243 if r.ci != nil {
233244 r.ci.ForgetRepo(name)
234245 }
M proto/ermite/storage/ci_artifact.proto +4 -2
@@ -1,8 +1,8 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
33 // Internal, unversioned storage schema of the CI artifacts (artifacts v4,
4-// CI-4 D3). The contents are not stored here: they are files under
5-// $ERMITE_DATA_DIR/ci/artifacts.
4+// CI-4 D3). Contents are stored separately, in the blob store or (for
5+// legacy records) under $ERMITE_DATA_DIR/ci/artifacts.
66
77 syntax = "proto3";
88
@@ -49,6 +49,8 @@ message CIArtifact {
4949 // list_digest: the SHA-256 of the block list of the commit, to recognize a
5050 // repeated commit.
5151 bytes list_digest = 15;
52+ // "" or "disk": legacy files under ci/artifacts; "blob": the blob store.
53+ string store = 16;
5254 }
5355
5456 // CIArtifactBlock is stored under "artifactblk/<artifact:020d>/<hex(block id)>":
A proto/ermite/storage/release.proto +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+syntax = "proto3";
4+package ermite.storage;
5+import "google/protobuf/timestamp.proto";
6+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
7+
8+// Release stores release metadata, not object contents.
9+message Release {
10+ int64 id = 1;
11+ string repo = 2;
12+ string tag = 3;
13+ string title = 4;
14+ string notes = 5;
15+ bool draft = 6;
16+ bool prerelease = 7;
17+ bool make_latest = 8;
18+ string target_commitish = 9;
19+ string author = 10;
20+ google.protobuf.Timestamp created_at = 11;
21+ google.protobuf.Timestamp published_at = 12;
22+ google.protobuf.Timestamp updated_at = 13;
23+}
24+
25+// ReleaseAsset stores release metadata, not object contents.
26+message ReleaseAsset {
27+ int64 id = 1;
28+ int64 release_id = 2;
29+ string repo = 3;
30+ string name = 4;
31+ string label = 5;
32+ string content_type = 6;
33+ int64 size = 7;
34+ string digest = 8;
35+ string state = 9;
36+ string uploader = 10;
37+ int64 charged = 11;
38+ string multipart_id = 12;
39+ google.protobuf.Timestamp created_at = 13;
40+ google.protobuf.Timestamp updated_at = 14;
41+}
42+
43+// ReleaseUsage stores release metadata, not object contents.
44+message ReleaseUsage {
45+ int64 used = 1;
46+}
M skills/ermite/CLI.md +20 -0
@@ -44,6 +44,7 @@ Scopes:
4444 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
4545 | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
4646 | `issue:write` | open, comment on, close and reopen issues |
47+| `release:write` | create, edit and delete releases and their assets; included in `cli`; refused for branch-limited tokens |
4748 | `token:admin` | create, list and revoke tokens |
4849 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
4950
@@ -93,6 +94,25 @@ Issues share the numbering of merge requests and must be enabled per repository:
9394
9495 Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.
9596
97+## Releases
98+
99+Needs the `releases` server feature. Read access follows repository visibility;
100+drafts need write access. Writes need `release:write`, never a branch-limited
101+token, or a CI job's `contents: write` permission. Select a release by tag or id;
102+`view` and `download` also accept `latest`.
103+
104+| Command | Notes |
105+|---|---|
106+| `release create <repo> <tag> [--title T] [--notes TEXT\|--notes-file F\|-] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]] [--json]` | existing tags never move; target creates a missing tag |
107+| `release list <repo> [--limit N] [--json]` | newest first |
108+| `release view <repo> <tag\|id\|latest> [--json\|--notes]` | metadata and assets |
109+| `release edit <repo> <tag\|id> [--title T] [--notes TEXT\|--notes-file F\|-] [--tag T] [--target REF] [--draft\|--publish] [--prerelease\|--no-prerelease] [--latest]` | only supplied fields change |
110+| `release delete <repo> <tag\|id> [--yes]` | leaves the Git tag |
111+| `release upload <repo> <tag\|id> <file[#label]>… [--clobber] [--content-type TYPE]` | clobber replaces same-named assets |
112+| `release download <repo> <tag\|id\|latest> [--name N]… [--pattern GLOB]… [--dir D]` | refuses existing local files |
113+
114+See [publishing and storage](../../docs/releases.md).
115+
96116 ## Workflows
97117
98118 | Command | Scope | Notes |

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close