detached d50b7cd

feat(app): wire releases and the blob store, S3 configuration, MinIO compose profile and docs

commit
d50b7cd4137471821f40569c63a0e6468b52981c
author
brice · · 2026-10-21 15:41 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
0933a48
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
47 files · +1172 -198
M .hexcheck.yaml +5 -0
@@ -68,6 +68,11 @@ mocking:
6868 generatedMockNamePatterns:
6969 - '{{Interface}}'
7070 allow:
71+ # Secret-source exclusivity and file decoding are configuration validation,
72+ # not domain policy; config is intentionally a composition-root helper.
73+ - rule: suspicious-business-logic-in-adapter
74+ path: internal/config/config.go
75+ reason: validates explicit environment and secret-file sources at startup
7176 # clientlimit is a shared infrastructure library, not a sibling adapter: the
7277 # HTTP adapters that rate-limit clients import it by design.
7378 - rule: no-adapter-to-adapter-imports
@@ -68,6 +68,11 @@ mocking:
68 generatedMockNamePatterns:68 generatedMockNamePatterns:
69 - '{{Interface}}'69 - '{{Interface}}'
70 allow:70 allow:
71+ # Secret-source exclusivity and file decoding are configuration validation,
72+ # not domain policy; config is intentionally a composition-root helper.
73+ - rule: suspicious-business-logic-in-adapter
74+ path: internal/config/config.go
75+ reason: validates explicit environment and secret-file sources at startup
71 # clientlimit is a shared infrastructure library, not a sibling adapter: the76 # clientlimit is a shared infrastructure library, not a sibling adapter: the
72 # HTTP adapters that rate-limit clients import it by design.77 # HTTP adapters that rate-limit clients import it by design.
73 - rule: no-adapter-to-adapter-imports78 - rule: no-adapter-to-adapter-imports
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.
@@ -18,6 +18,7 @@
18 - **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container18 - **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
19 - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents19 - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents
20 - **Safe for agents**: an agent token can only push its own branches; you review and merge20 - **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)
21 - Git over HTTPS, web interface with live updates22 - Git over HTTPS, web interface with live updates
22 23
23 Status: early development.24 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:
@@ -24,13 +24,52 @@ services:
24 ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-012345678924 ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-0123456789
25 ERMITE_LOG_FORMAT: console25 ERMITE_LOG_FORMAT: console
26 ERMITE_LOG_LEVEL: debug26 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:-}
27 volumes:35 volumes:
28 - .:/src36 - .:/src
29 - go-mod:/go/pkg/mod37 - go-mod:/go/pkg/mod
30 - go-build:/root/.cache/go-build38 - go-build:/root/.cache/go-build
31 - data:/data39 - data:/data
32 40
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+
33 volumes:71 volumes:
72+ minio-data:
34 go-mod:73 go-mod:
35 go-build:74 go-build:
36 data:75 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
@@ -13,7 +13,8 @@ The run page lists them with download links.
13 13
14 - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.14 - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.
15 - Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.15 - 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.
17 - An upload not finished when its job ends is dropped.18 - An upload not finished when its job ends is dropped.
18 19
19 ## Limits20 ## Limits
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 5. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
@@ -60,10 +60,38 @@ head -c 32 /dev/urandom | base64
60 - The server refuses to start with a different key than the one the secrets were sealed with.60 - The server refuses to start with a different key than the one the secrets were sealed with.
61 - Losing the key loses every secret. A backup without the key reveals none.61 - Losing the key loses every secret. A backup without the key reveals none.
62 62
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+
63 ## Runtime files91 ## Runtime files
64 92
65 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.93 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.
66 94
67 ## Storage schema95 ## Storage schema
68 96
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 5. 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.
@@ -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 +89 -0
@@ -0,0 +1,89 @@
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+
55+## Backend changes
56+
57+Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until
58+expiry; there is no automatic copy. New artifacts and all release assets use the
59+currently selected backend. **Do not switch backends with live blob-backed data**:
60+records identify blobs, not their backend. Switching disk to S3, changing bucket
61+or prefix, or switching back makes earlier blob-backed contents unavailable.
62+Keep the selected backend stable, or arrange a complete offline copy yourself.
63+
64+## Development compose
65+
66+Disk (no MinIO services):
67+
68+```sh
69+ERMITE_S3_BUCKET= docker compose up
70+```
71+
72+MinIO provisioning must finish before the forge starts its probe:
73+
74+```sh
75+docker compose --profile s3 up -d minio
76+docker compose --profile s3 run --rm minio-init
77+ERMITE_S3_BUCKET=ermite ERMITE_S3_ENDPOINT=http://minio:9000 \
78+ERMITE_S3_REGION=us-east-1 ERMITE_S3_ACCESS_KEY_ID=minioadmin \
79+ERMITE_S3_SECRET_ACCESS_KEY=minioadmin ERMITE_S3_FORCE_PATH_STYLE=1 \
80+ERMITE_S3_PREFIX=dev/ docker compose --profile s3 up ermite
81+```
82+
83+Console: <http://localhost:9001>, development credentials `minioadmin` /
84+`minioadmin`. The init service creates `ermite` and `ermite-test`; only that
85+provisioning service has bucket creation duties. For Scaleway or another external
86+service, use the same environment variables with its existing bucket, endpoint,
87+region and keys, and `docker compose up ermite` without the profile. These are
88+development settings; never use the placeholder admin token or MinIO keys in
89+production.
@@ -0,0 +1,89 @@
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+
55+## Backend changes
56+
57+Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until
58+expiry; there is no automatic copy. New artifacts and all release assets use the
59+currently selected backend. **Do not switch backends with live blob-backed data**:
60+records identify blobs, not their backend. Switching disk to S3, changing bucket
61+or prefix, or switching back makes earlier blob-backed contents unavailable.
62+Keep the selected backend stable, or arrange a complete offline copy yourself.
63+
64+## Development compose
65+
66+Disk (no MinIO services):
67+
68+```sh
69+ERMITE_S3_BUCKET= docker compose up
70+```
71+
72+MinIO provisioning must finish before the forge starts its probe:
73+
74+```sh
75+docker compose --profile s3 up -d minio
76+docker compose --profile s3 run --rm minio-init
77+ERMITE_S3_BUCKET=ermite ERMITE_S3_ENDPOINT=http://minio:9000 \
78+ERMITE_S3_REGION=us-east-1 ERMITE_S3_ACCESS_KEY_ID=minioadmin \
79+ERMITE_S3_SECRET_ACCESS_KEY=minioadmin ERMITE_S3_FORCE_PATH_STYLE=1 \
80+ERMITE_S3_PREFIX=dev/ docker compose --profile s3 up ermite
81+```
82+
83+Console: <http://localhost:9001>, development credentials `minioadmin` /
84+`minioadmin`. The init service creates `ermite` and `ermite-test`; only that
85+provisioning service has bucket creation duties. For Scaleway or another external
86+service, use the same environment variables with its existing bucket, endpoint,
87+region and keys, and `docker compose up ermite` without the profile. These are
88+development settings; never use the placeholder admin token or MinIO keys in
89+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).
@@ -15,6 +15,14 @@ The secret is printed once. Presets and `--scope` add up (`issue:write` lets a t
15 | `ci` | `repo:read` |15 | `ci` | `repo:read` |
16 | `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |16 | `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |
17 17
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+
18 ## Branch-limited tokens26 ## Branch-limited tokens
19 27
20 For an AI agent that should propose changes, not publish them:28 For an AI agent that should propose changes, not publish them:
@@ -38,6 +46,7 @@ The token **cannot**:
38 46
39 - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;47 - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;
40 - create, delete or configure a repository;48 - create, delete or configure a repository;
49+- create, edit or delete releases or upload assets;
41 - open web sessions or get owner rights, even with every scope;50 - open web sessions or get owner rights, even with every scope;
42 - manage runners, even with `runner:register`;51 - manage runners, even with `runner:register`;
43 - get trusted CI runs (no secrets). See [CI trust](ci.md#trust).52 - get trusted CI runs (no secrets). See [CI trust](ci.md#trust).
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 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 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)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/ci_artifacts_test.go +11 -14
@@ -49,18 +49,11 @@ jobs:
4949 - run: echo down
5050 `
5151
52-// newArtifactHarness supplies the P8 blob wiring through the existing test
53-// hook. Production wiring remains owned by P8.
52+// newArtifactHarness uses the production blob wiring.
5453 func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
5554 t.Helper()
56- root := t.TempDir()
57- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
58- cfg.Blobs = blobdisk.New(blobdisk.Dir(root))
59- if tweak != nil {
60- tweak(cfg)
61- }
62- })
63- c.artifactRoot = blobdisk.Dir(root)
55+ c := newCIHarness(t, tweak)
56+ c.artifactRoot = blobdisk.Dir(c.data)
6457 return c
6558 }
6659
@@ -583,7 +576,9 @@ func TestCIArtifactLimits(t *testing.T) {
583576
584577 func TestCIArtifactsSweep(t *testing.T) {
585578 clk := &shiftClock{}
579+ legacyRoot := ciartifacts.Dir(t.TempDir())
586580 c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
581+ cfg.ArtifactFiles = ciartifacts.New(legacyRoot)
587582 cfg.Clock = clk
588583 cfg.Tick = 20 * time.Millisecond
589584 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour
@@ -599,10 +594,10 @@ func TestCIArtifactsSweep(t *testing.T) {
599594 crashed := up.mustCreate("crashed")
600595 _, err := up.block(crashed, "x", []byte("stale"))
601596 require.NoError(t, err)
602- 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)))
603598 require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700))
604599 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))
605- strayRun := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", int64(888888)))
600+ strayRun := filepath.Join(legacyRoot, fmt.Sprintf("%020d", int64(888888)))
606601 require.NoError(t, os.MkdirAll(strayRun, 0o700))
607602 start := clk.Now()
608603
@@ -807,7 +802,9 @@ func TestCIArtifactCaps(t *testing.T) {
807802
808803 // Both storage generations remain downloadable through the real RPC handlers.
809804 func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
810- c := newArtifactHarness(t, nil)
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) })
811808 runner := c.registerRunner("alpha", "ubuntu-latest")
812809 _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
813810 up := c.jobArtifacts(job)
@@ -829,7 +826,7 @@ func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
829826 legacy := up.upload("old", []byte("old disk"))
830827 a, err = store.GetArtifact(c.ctx, legacy.GetId())
831828 require.NoError(t, err)
832- oldPath := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
829+ oldPath := filepath.Join(legacyRoot, fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
833830 require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700))
834831 require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600))
835832 require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))))
@@ -49,18 +49,11 @@ jobs:
49 - run: echo down49 - run: echo down
50 `50 `
51 51
52-// newArtifactHarness supplies the P8 blob wiring through the existing test52+// newArtifactHarness uses the production blob wiring.
53-// hook. Production wiring remains owned by P8.
54 func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {53 func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
55 t.Helper()54 t.Helper()
56- root := t.TempDir()55+ c := newCIHarness(t, tweak)
57- c := newCIHarness(t, func(cfg *usecase.CIConfig) {56+ c.artifactRoot = blobdisk.Dir(c.data)
58- cfg.Blobs = blobdisk.New(blobdisk.Dir(root))
59- if tweak != nil {
60- tweak(cfg)
61- }
62- })
63- c.artifactRoot = blobdisk.Dir(root)
64 return c57 return c
65 }58 }
66 59
@@ -583,7 +576,9 @@ func TestCIArtifactLimits(t *testing.T) {
583 576
584 func TestCIArtifactsSweep(t *testing.T) {577 func TestCIArtifactsSweep(t *testing.T) {
585 clk := &shiftClock{}578 clk := &shiftClock{}
579+ legacyRoot := ciartifacts.Dir(t.TempDir())
586 c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {580 c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
581+ cfg.ArtifactFiles = ciartifacts.New(legacyRoot)
587 cfg.Clock = clk582 cfg.Clock = clk
588 cfg.Tick = 20 * time.Millisecond583 cfg.Tick = 20 * time.Millisecond
589 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour584 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour
@@ -599,10 +594,10 @@ func TestCIArtifactsSweep(t *testing.T) {
599 crashed := up.mustCreate("crashed")594 crashed := up.mustCreate("crashed")
600 _, err := up.block(crashed, "x", []byte("stale"))595 _, err := up.block(crashed, "x", []byte("stale"))
601 require.NoError(t, err)596 require.NoError(t, err)
602- 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)))
603 require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700))598 require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700))
604 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))599 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))
605- strayRun := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", int64(888888)))600+ strayRun := filepath.Join(legacyRoot, fmt.Sprintf("%020d", int64(888888)))
606 require.NoError(t, os.MkdirAll(strayRun, 0o700))601 require.NoError(t, os.MkdirAll(strayRun, 0o700))
607 start := clk.Now()602 start := clk.Now()
608 603
@@ -807,7 +802,9 @@ func TestCIArtifactCaps(t *testing.T) {
807 802
808 // Both storage generations remain downloadable through the real RPC handlers.803 // Both storage generations remain downloadable through the real RPC handlers.
809 func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {804 func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
810- c := newArtifactHarness(t, nil)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) })
811 runner := c.registerRunner("alpha", "ubuntu-latest")808 runner := c.registerRunner("alpha", "ubuntu-latest")
812 _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})809 _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
813 up := c.jobArtifacts(job)810 up := c.jobArtifacts(job)
@@ -829,7 +826,7 @@ func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
829 legacy := up.upload("old", []byte("old disk"))826 legacy := up.upload("old", []byte("old disk"))
830 a, err = store.GetArtifact(c.ctx, legacy.GetId())827 a, err = store.GetArtifact(c.ctx, legacy.GetId())
831 require.NoError(t, err)828 require.NoError(t, err)
832- oldPath := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))829+ oldPath := filepath.Join(legacyRoot, fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
833 require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700))830 require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700))
834 require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600))831 require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600))
835 require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))))832 require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))))
M internal/app/cli_e2e_test.go +7 -7
@@ -334,9 +334,9 @@ 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. Releases are wired by P8; this rig predates it.
337+ // passkeys are not available.
338338 res = c.cli(t, "", "version", "--json").ok(t)
339- assert.Equal(t, 2, strings.Count(res.stderr, "warning:"), res.stderr)
339+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
340340 assert.Contains(t, res.stderr, "the server does not support passkeys")
341341 type versionOut struct {
342342 Client struct{ Version string }
@@ -350,12 +350,12 @@ 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)
354- assert.Equal(t, []string{"releases", "passkeys"}, ver.Server.MissingFeatures)
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)
354+ assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
357357 assert.Contains(t, stdout, "server")
358- assert.Equal(t, 2, strings.Count(stderr, "warning:"), stderr)
358+ assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
359359 assert.Contains(t, stderr, "the server does not support passkeys")
360360
361361 // Tokens: create a second one, list, revoke it, see it stop working.
@@ -389,8 +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())-1, "the cli preset drops release:write until P8 wires releases")
393- assert.NotContains(t, byLabel["daily"].Scopes, "release:write")
392+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()))
393+ assert.Contains(t, byLabel["daily"].Scopes, "release:write")
394394 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
395395 assert.NotContains(t, res.stdout, `"secret"`)
396396 res = c.cli(t, secret, "token", "list").ok(t)
@@ -334,9 +334,9 @@ func TestAC6CLIEndToEnd(t *testing.T) {
334 assert.JSONEq(t, `[]`, res.stdout)334 assert.JSONEq(t, `[]`, res.stdout)
335 335
336 // version: no token needed. This server runs on http://127.0.0.1, where336 // version: no token needed. This server runs on http://127.0.0.1, where
337- // passkeys are not available. Releases are wired by P8; this rig predates it.337+ // passkeys are not available.
338 res = c.cli(t, "", "version", "--json").ok(t)338 res = c.cli(t, "", "version", "--json").ok(t)
339- assert.Equal(t, 2, strings.Count(res.stderr, "warning:"), res.stderr)339+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
340 assert.Contains(t, res.stderr, "the server does not support passkeys")340 assert.Contains(t, res.stderr, "the server does not support passkeys")
341 type versionOut struct {341 type versionOut struct {
342 Client struct{ Version string }342 Client struct{ Version string }
@@ -350,12 +350,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 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)
354- assert.Equal(t, []string{"releases", "passkeys"}, ver.Server.MissingFeatures)354+ assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
357 assert.Contains(t, stdout, "server")357 assert.Contains(t, stdout, "server")
358- assert.Equal(t, 2, strings.Count(stderr, "warning:"), stderr)358+ assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
359 assert.Contains(t, stderr, "the server does not support passkeys")359 assert.Contains(t, stderr, "the server does not support passkeys")
360 360
361 // Tokens: create a second one, list, revoke it, see it stop working.361 // Tokens: create a second one, list, revoke it, see it stop working.
@@ -389,8 +389,8 @@ func TestAC6CLIEndToEnd(t *testing.T) {
389 assert.Empty(t, tk.Secret, "list never shows secrets")389 assert.Empty(t, tk.Secret, "list never shows secrets")
390 byLabel[tk.Label] = tk390 byLabel[tk.Label] = tk
391 }391 }
392- assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes())-1, "the cli preset drops release:write until P8 wires releases")392+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()))
393- assert.NotContains(t, byLabel["daily"].Scopes, "release:write")393+ assert.Contains(t, byLabel["daily"].Scopes, "release:write")
394 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)394 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
395 assert.NotContains(t, res.stdout, `"secret"`)395 assert.NotContains(t, res.stdout, `"secret"`)
396 res = c.cli(t, secret, "token", "list").ok(t)396 res = c.cli(t, secret, "token", "list").ok(t)
M internal/app/cli_release_e2e_test.go +7 -16
@@ -13,10 +13,7 @@ import (
1313 "github.com/stretchr/testify/assert"
1414 "github.com/stretchr/testify/require"
1515
16- "bnema.dev/ermite/internal/adapters/blobdisk"
1716 "bnema.dev/ermite/internal/adapters/rpc"
18- "bnema.dev/ermite/internal/ports"
19- "bnema.dev/ermite/internal/usecase"
2017 )
2118
2219 func TestReleaseCLIEndToEnd(t *testing.T) {
@@ -26,17 +23,6 @@ func TestReleaseCLIEndToEnd(t *testing.T) {
2623 c.mustGit(wt, "tag", "v1")
2724 c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
2825
29- // P8 owns production wiring. Compose the real release service and RPC here,
30- // sharing the forge's database, auth, git reader, pushes and event bus.
31- f := c.forge
32- releases := usecase.NewReleases(f.store.Releases(), blobdisk.New(filepath.Join(c.data, "release-test-blobs")), f.store, f.git, f.git, f.Pushes, f.Bus, systemClock{}, ports.ReleaseLimits{})
33- api := rpc.New(rpc.Config{Auth: f.Auth, Releases: releases, SigningKey: f.SigningKey, Sessions: f.Sessions})
34- mux := http.NewServeMux()
35- mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
36- srv := httptest.NewServer(mux)
37- t.Cleanup(srv.Close)
38- oldURL := c.url
39- c.url = srv.URL
4026 bin := func(args ...string) string {
4127 t.Helper()
4228 out, stderr, code := c.bin(t, adminToken, args...)
@@ -67,8 +53,13 @@ func TestReleaseCLIEndToEnd(t *testing.T) {
6753 bin("release", "delete", "proj", "v1", "--yes")
6854 _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
6955 requireOneLineError(t, stderr, code, "not found")
70- // The unmodified forge intentionally has no release wiring yet.
71- c.url = oldURL
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
7263 _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
7364 requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
7465 }
@@ -13,10 +13,7 @@ import (
13 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16- "bnema.dev/ermite/internal/adapters/blobdisk"
17 "bnema.dev/ermite/internal/adapters/rpc"16 "bnema.dev/ermite/internal/adapters/rpc"
18- "bnema.dev/ermite/internal/ports"
19- "bnema.dev/ermite/internal/usecase"
20 )17 )
21 18
22 func TestReleaseCLIEndToEnd(t *testing.T) {19 func TestReleaseCLIEndToEnd(t *testing.T) {
@@ -26,17 +23,6 @@ func TestReleaseCLIEndToEnd(t *testing.T) {
26 c.mustGit(wt, "tag", "v1")23 c.mustGit(wt, "tag", "v1")
27 c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")24 c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
28 25
29- // P8 owns production wiring. Compose the real release service and RPC here,
30- // sharing the forge's database, auth, git reader, pushes and event bus.
31- f := c.forge
32- releases := usecase.NewReleases(f.store.Releases(), blobdisk.New(filepath.Join(c.data, "release-test-blobs")), f.store, f.git, f.git, f.Pushes, f.Bus, systemClock{}, ports.ReleaseLimits{})
33- api := rpc.New(rpc.Config{Auth: f.Auth, Releases: releases, SigningKey: f.SigningKey, Sessions: f.Sessions})
34- mux := http.NewServeMux()
35- mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
36- srv := httptest.NewServer(mux)
37- t.Cleanup(srv.Close)
38- oldURL := c.url
39- c.url = srv.URL
40 bin := func(args ...string) string {26 bin := func(args ...string) string {
41 t.Helper()27 t.Helper()
42 out, stderr, code := c.bin(t, adminToken, args...)28 out, stderr, code := c.bin(t, adminToken, args...)
@@ -67,8 +53,13 @@ func TestReleaseCLIEndToEnd(t *testing.T) {
67 bin("release", "delete", "proj", "v1", "--yes")53 bin("release", "delete", "proj", "v1", "--yes")
68 _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")54 _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
69 requireOneLineError(t, stderr, code, "not found")55 requireOneLineError(t, stderr, code, "not found")
70- // The unmodified forge intentionally has no release wiring yet.56+ // An older server without the service still gets a clear refusal.
71- c.url = oldURL57+ 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
72 _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")63 _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
73 requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")64 requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
74 }65 }
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()
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // 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"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 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)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 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)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 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")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 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)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 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) {
@@ -17,6 +17,8 @@ import (
17 17
18 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
19 19
20+ "bnema.dev/ermite/internal/adapters/blobdisk"
21+ "bnema.dev/ermite/internal/adapters/blobs3"
20 "bnema.dev/ermite/internal/adapters/ciartifacts"22 "bnema.dev/ermite/internal/adapters/ciartifacts"
21 "bnema.dev/ermite/internal/adapters/cilogs"23 "bnema.dev/ermite/internal/adapters/cilogs"
22 "bnema.dev/ermite/internal/adapters/cisummaries"24 "bnema.dev/ermite/internal/adapters/cisummaries"
@@ -153,7 +155,9 @@ type forge struct {
153 EventLog *usecase.Events155 EventLog *usecase.Events
154 // CI is the CI engine: workflow trigger, planner, runner protocol and the156 // CI is the CI engine: workflow trigger, planner, runner protocol and the
155 // owner side. It starts with the server and stops with it.157 // owner side. It starts with the server and stops with it.
156- CI *usecase.CI158+ CI *usecase.CI
159+ Releases *usecase.Releases
160+ Blobs ports.BlobStore
157 // Settings are the secrets, variables and environments of CI.161 // Settings are the secrets, variables and environments of CI.
158 Settings *usecase.CISettings162 Settings *usecase.CISettings
159 // Mirrors exposes Warning and Badge for the hook server and web composition.163 // 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
268 }272 }
269 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")273 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
270 f.Browse = usecase.NewBrowse(store, reader)274 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)
271 282
272 // Secrets, variables and environments (ADR 0019, CI-3). With a key the283 // Secrets, variables and environments (ADR 0019, CI-3). With a key the
273 // secrets are sealed under it, and the server refuses to start when the284 // 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
335 OIDC: f.OIDC,346 OIDC: f.OIDC,
336 }347 }
337 artifacts(&ciCfg, cfg, store)348 artifacts(&ciCfg, cfg, store)
349+ ciCfg.Blobs = f.Blobs
350+ ciCfg.ReleaseSweeper = f.Releases
338 f.pullRequests(&ciCfg, store, git, committer)351 f.pullRequests(&ciCfg, store, git, committer)
339 if opts.ci != nil {352 if opts.ci != nil {
340 opts.ci(&ciCfg)353 opts.ci(&ciCfg)
@@ -459,6 +472,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
459 OIDCKeys: f.OIDC,472 OIDCKeys: f.OIDC,
460 JobArtifacts: jobArtifacts,473 JobArtifacts: jobArtifacts,
461 Artifacts: artifacts,474 Artifacts: artifacts,
475+ Releases: f.Releases,
462 Version: serverVersion(),476 Version: serverVersion(),
463 TrustedProxies: cfg.TrustedProxies,477 TrustedProxies: cfg.TrustedProxies,
464 Limiter: limiter,478 Limiter: limiter,
@@ -487,7 +501,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
487 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})501 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
488 }502 }
489 // The REST subset the actions call on github.api_url.503 // 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})
491 for _, p := range githubapi.Patterns {506 for _, p := range githubapi.Patterns {
492 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})507 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
493 }508 }
@@ -593,16 +608,46 @@ func (f *forge) Close(ctx context.Context) {
593 }608 }
594 609
595 // artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):610 // 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=0611+// records in the kv store, legacy contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
597 // gives none, and the API then serves no artifact service.612 // gives none, and the API then serves no artifact service.
598 func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {613 func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
599 if !cfg.CIArtifacts {614 if !cfg.CIArtifacts {
600 return615 return
601 }616 }
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+ }
603 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}621 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
604 }622 }
605 623
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+
606 // pullRequests gives the CI the merge requests, the refs/pull refs and the651 // pullRequests gives the CI the merge requests, the refs/pull refs and the
607 // identity of the test merge commits (CI-3, D5).652 // identity of the test merge commits (CI-3, D5).
608 func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {653 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+}
@@ -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/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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -358,31 +362,37 @@
358 <span class="k">section</span>362 <span class="k">section</span>
359 </a>363 </a>
360 </li>364 </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">
362 <a href="/files/actions" tabindex="-1">372 <a href="/files/actions" tabindex="-1">
363 <span class="lbl">actions</span>373 <span class="lbl">actions</span>
364 <span class="k">section</span>374 <span class="k">section</span>
365 </a>375 </a>
366 </li>376 </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">
368 <a href="/files/tree/feature/x" tabindex="-1">378 <a href="/files/tree/feature/x" tabindex="-1">
369 <span class="lbl">feature/x</span>379 <span class="lbl">feature/x</span>
370 <span class="k">branch</span>380 <span class="k">branch</span>
371 </a>381 </a>
372 </li>382 </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">
374 <a href="/files/tree/main" tabindex="-1">384 <a href="/files/tree/main" tabindex="-1">
375 <span class="lbl">main</span>385 <span class="lbl">main</span>
376 <span class="k">branch</span>386 <span class="k">branch</span>
377 </a>387 </a>
378 </li>388 </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">
380 <a href="/files/tree/v1.0" tabindex="-1">390 <a href="/files/tree/v1.0" tabindex="-1">
381 <span class="lbl">v1.0</span>391 <span class="lbl">v1.0</span>
382 <span class="k">tag</span>392 <span class="k">tag</span>
383 </a>393 </a>
384 </li>394 </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">
386 <a href="#" data-fire="keys" tabindex="-1">396 <a href="#" data-fire="keys" tabindex="-1">
387 <span class="lbl">turn single-key shortcuts off</span>397 <span class="lbl">turn single-key shortcuts off</span>
388 <span class="k">action</span>398 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -428,31 +432,37 @@
428 <span class="k">section</span>432 <span class="k">section</span>
429 </a>433 </a>
430 </li>434 </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">
432 <a href="/files/actions" tabindex="-1">442 <a href="/files/actions" tabindex="-1">
433 <span class="lbl">actions</span>443 <span class="lbl">actions</span>
434 <span class="k">section</span>444 <span class="k">section</span>
435 </a>445 </a>
436 </li>446 </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">
438 <a href="/files/tree/feature/x" tabindex="-1">448 <a href="/files/tree/feature/x" tabindex="-1">
439 <span class="lbl">feature/x</span>449 <span class="lbl">feature/x</span>
440 <span class="k">branch</span>450 <span class="k">branch</span>
441 </a>451 </a>
442 </li>452 </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">
444 <a href="/files/tree/main" tabindex="-1">454 <a href="/files/tree/main" tabindex="-1">
445 <span class="lbl">main</span>455 <span class="lbl">main</span>
446 <span class="k">branch</span>456 <span class="k">branch</span>
447 </a>457 </a>
448 </li>458 </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">
450 <a href="/files/tree/v1.0" tabindex="-1">460 <a href="/files/tree/v1.0" tabindex="-1">
451 <span class="lbl">v1.0</span>461 <span class="lbl">v1.0</span>
452 <span class="k">tag</span>462 <span class="k">tag</span>
453 </a>463 </a>
454 </li>464 </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">
456 <a href="#" data-fire="keys" tabindex="-1">466 <a href="#" data-fire="keys" tabindex="-1">
457 <span class="lbl">turn single-key shortcuts off</span>467 <span class="lbl">turn single-key shortcuts off</span>
458 <span class="k">action</span>468 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -358,31 +362,37 @@
358 <span class="k">section</span>362 <span class="k">section</span>
359 </a>363 </a>
360 </li>364 </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">
362 <a href="/files/actions" tabindex="-1">372 <a href="/files/actions" tabindex="-1">
363 <span class="lbl">actions</span>373 <span class="lbl">actions</span>
364 <span class="k">section</span>374 <span class="k">section</span>
365 </a>375 </a>
366 </li>376 </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">
368 <a href="/files/tree/feature/x" tabindex="-1">378 <a href="/files/tree/feature/x" tabindex="-1">
369 <span class="lbl">feature/x</span>379 <span class="lbl">feature/x</span>
370 <span class="k">branch</span>380 <span class="k">branch</span>
371 </a>381 </a>
372 </li>382 </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">
374 <a href="/files/tree/main" tabindex="-1">384 <a href="/files/tree/main" tabindex="-1">
375 <span class="lbl">main</span>385 <span class="lbl">main</span>
376 <span class="k">branch</span>386 <span class="k">branch</span>
377 </a>387 </a>
378 </li>388 </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">
380 <a href="/files/tree/v1.0" tabindex="-1">390 <a href="/files/tree/v1.0" tabindex="-1">
381 <span class="lbl">v1.0</span>391 <span class="lbl">v1.0</span>
382 <span class="k">tag</span>392 <span class="k">tag</span>
383 </a>393 </a>
384 </li>394 </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">
386 <a href="#" data-fire="keys" tabindex="-1">396 <a href="#" data-fire="keys" tabindex="-1">
387 <span class="lbl">turn single-key shortcuts off</span>397 <span class="lbl">turn single-key shortcuts off</span>
388 <span class="k">action</span>398 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -360,31 +364,37 @@
360 <span class="k">section</span>364 <span class="k">section</span>
361 </a>365 </a>
362 </li>366 </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">
364 <a href="/files/actions" tabindex="-1">374 <a href="/files/actions" tabindex="-1">
365 <span class="lbl">actions</span>375 <span class="lbl">actions</span>
366 <span class="k">section</span>376 <span class="k">section</span>
367 </a>377 </a>
368 </li>378 </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">
370 <a href="/files/tree/feature/x" tabindex="-1">380 <a href="/files/tree/feature/x" tabindex="-1">
371 <span class="lbl">feature/x</span>381 <span class="lbl">feature/x</span>
372 <span class="k">branch</span>382 <span class="k">branch</span>
373 </a>383 </a>
374 </li>384 </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">
376 <a href="/files/tree/main" tabindex="-1">386 <a href="/files/tree/main" tabindex="-1">
377 <span class="lbl">main</span>387 <span class="lbl">main</span>
378 <span class="k">branch</span>388 <span class="k">branch</span>
379 </a>389 </a>
380 </li>390 </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">
382 <a href="/files/tree/v1.0" tabindex="-1">392 <a href="/files/tree/v1.0" tabindex="-1">
383 <span class="lbl">v1.0</span>393 <span class="lbl">v1.0</span>
384 <span class="k">tag</span>394 <span class="k">tag</span>
385 </a>395 </a>
386 </li>396 </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">
388 <a href="#" data-fire="keys" tabindex="-1">398 <a href="#" data-fire="keys" tabindex="-1">
389 <span class="lbl">turn single-key shortcuts off</span>399 <span class="lbl">turn single-key shortcuts off</span>
390 <span class="k">action</span>400 <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>
@@ -96,6 +96,10 @@
96 </a>96 </a>
97 </li>97 </li>
98 <li>98 <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>
99 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>103 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100 </a>104 </a>
101 </li>105 </li>
@@ -425,31 +429,37 @@
425 <span class="k">section</span>429 <span class="k">section</span>
426 </a>430 </a>
427 </li>431 </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">
429 <a href="/files/actions" tabindex="-1">439 <a href="/files/actions" tabindex="-1">
430 <span class="lbl">actions</span>440 <span class="lbl">actions</span>
431 <span class="k">section</span>441 <span class="k">section</span>
432 </a>442 </a>
433 </li>443 </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">
435 <a href="/files/tree/feature/x" tabindex="-1">445 <a href="/files/tree/feature/x" tabindex="-1">
436 <span class="lbl">feature/x</span>446 <span class="lbl">feature/x</span>
437 <span class="k">branch</span>447 <span class="k">branch</span>
438 </a>448 </a>
439 </li>449 </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">
441 <a href="/files/tree/main" tabindex="-1">451 <a href="/files/tree/main" tabindex="-1">
442 <span class="lbl">main</span>452 <span class="lbl">main</span>
443 <span class="k">branch</span>453 <span class="k">branch</span>
444 </a>454 </a>
445 </li>455 </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">
447 <a href="/files/tree/v1.0" tabindex="-1">457 <a href="/files/tree/v1.0" tabindex="-1">
448 <span class="lbl">v1.0</span>458 <span class="lbl">v1.0</span>
449 <span class="k">tag</span>459 <span class="k">tag</span>
450 </a>460 </a>
451 </li>461 </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">
453 <a href="#" data-fire="keys" tabindex="-1">463 <a href="#" data-fire="keys" tabindex="-1">
454 <span class="lbl">turn single-key shortcuts off</span>464 <span class="lbl">turn single-key shortcuts off</span>
455 <span class="k">action</span>465 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -383,31 +387,37 @@
383 <span class="k">section</span>387 <span class="k">section</span>
384 </a>388 </a>
385 </li>389 </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">
387 <a href="/files/actions" tabindex="-1">397 <a href="/files/actions" tabindex="-1">
388 <span class="lbl">actions</span>398 <span class="lbl">actions</span>
389 <span class="k">section</span>399 <span class="k">section</span>
390 </a>400 </a>
391 </li>401 </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">
393 <a href="/files/tree/feature/x" tabindex="-1">403 <a href="/files/tree/feature/x" tabindex="-1">
394 <span class="lbl">feature/x</span>404 <span class="lbl">feature/x</span>
395 <span class="k">branch</span>405 <span class="k">branch</span>
396 </a>406 </a>
397 </li>407 </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">
399 <a href="/files/tree/main" tabindex="-1">409 <a href="/files/tree/main" tabindex="-1">
400 <span class="lbl">main</span>410 <span class="lbl">main</span>
401 <span class="k">branch</span>411 <span class="k">branch</span>
402 </a>412 </a>
403 </li>413 </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">
405 <a href="/files/tree/v1.0" tabindex="-1">415 <a href="/files/tree/v1.0" tabindex="-1">
406 <span class="lbl">v1.0</span>416 <span class="lbl">v1.0</span>
407 <span class="k">tag</span>417 <span class="k">tag</span>
408 </a>418 </a>
409 </li>419 </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">
411 <a href="#" data-fire="keys" tabindex="-1">421 <a href="#" data-fire="keys" tabindex="-1">
412 <span class="lbl">turn single-key shortcuts off</span>422 <span class="lbl">turn single-key shortcuts off</span>
413 <span class="k">action</span>423 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -378,31 +382,37 @@
378 <span class="k">section</span>382 <span class="k">section</span>
379 </a>383 </a>
380 </li>384 </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">
382 <a href="/files/actions" tabindex="-1">392 <a href="/files/actions" tabindex="-1">
383 <span class="lbl">actions</span>393 <span class="lbl">actions</span>
384 <span class="k">section</span>394 <span class="k">section</span>
385 </a>395 </a>
386 </li>396 </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">
388 <a href="/files/tree/feature/x" tabindex="-1">398 <a href="/files/tree/feature/x" tabindex="-1">
389 <span class="lbl">feature/x</span>399 <span class="lbl">feature/x</span>
390 <span class="k">branch</span>400 <span class="k">branch</span>
391 </a>401 </a>
392 </li>402 </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">
394 <a href="/files/tree/main" tabindex="-1">404 <a href="/files/tree/main" tabindex="-1">
395 <span class="lbl">main</span>405 <span class="lbl">main</span>
396 <span class="k">branch</span>406 <span class="k">branch</span>
397 </a>407 </a>
398 </li>408 </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">
400 <a href="/files/tree/v1.0" tabindex="-1">410 <a href="/files/tree/v1.0" tabindex="-1">
401 <span class="lbl">v1.0</span>411 <span class="lbl">v1.0</span>
402 <span class="k">tag</span>412 <span class="k">tag</span>
403 </a>413 </a>
404 </li>414 </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">
406 <a href="#" data-fire="keys" tabindex="-1">416 <a href="#" data-fire="keys" tabindex="-1">
407 <span class="lbl">turn single-key shortcuts off</span>417 <span class="lbl">turn single-key shortcuts off</span>
408 <span class="k">action</span>418 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -358,31 +362,37 @@
358 <span class="k">section</span>362 <span class="k">section</span>
359 </a>363 </a>
360 </li>364 </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">
362 <a href="/files/actions" tabindex="-1">372 <a href="/files/actions" tabindex="-1">
363 <span class="lbl">actions</span>373 <span class="lbl">actions</span>
364 <span class="k">section</span>374 <span class="k">section</span>
365 </a>375 </a>
366 </li>376 </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">
368 <a href="/files/tree/feature/x" tabindex="-1">378 <a href="/files/tree/feature/x" tabindex="-1">
369 <span class="lbl">feature/x</span>379 <span class="lbl">feature/x</span>
370 <span class="k">branch</span>380 <span class="k">branch</span>
371 </a>381 </a>
372 </li>382 </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">
374 <a href="/files/tree/main" tabindex="-1">384 <a href="/files/tree/main" tabindex="-1">
375 <span class="lbl">main</span>385 <span class="lbl">main</span>
376 <span class="k">branch</span>386 <span class="k">branch</span>
377 </a>387 </a>
378 </li>388 </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">
380 <a href="/files/tree/v1.0" tabindex="-1">390 <a href="/files/tree/v1.0" tabindex="-1">
381 <span class="lbl">v1.0</span>391 <span class="lbl">v1.0</span>
382 <span class="k">tag</span>392 <span class="k">tag</span>
383 </a>393 </a>
384 </li>394 </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">
386 <a href="#" data-fire="keys" tabindex="-1">396 <a href="#" data-fire="keys" tabindex="-1">
387 <span class="lbl">turn single-key shortcuts off</span>397 <span class="lbl">turn single-key shortcuts off</span>
388 <span class="k">action</span>398 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -425,31 +429,37 @@
425 <span class="k">section</span>429 <span class="k">section</span>
426 </a>430 </a>
427 </li>431 </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">
429 <a href="/files/actions" tabindex="-1">439 <a href="/files/actions" tabindex="-1">
430 <span class="lbl">actions</span>440 <span class="lbl">actions</span>
431 <span class="k">section</span>441 <span class="k">section</span>
432 </a>442 </a>
433 </li>443 </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">
435 <a href="/files/tree/feature/x" tabindex="-1">445 <a href="/files/tree/feature/x" tabindex="-1">
436 <span class="lbl">feature/x</span>446 <span class="lbl">feature/x</span>
437 <span class="k">branch</span>447 <span class="k">branch</span>
438 </a>448 </a>
439 </li>449 </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">
441 <a href="/files/tree/main" tabindex="-1">451 <a href="/files/tree/main" tabindex="-1">
442 <span class="lbl">main</span>452 <span class="lbl">main</span>
443 <span class="k">branch</span>453 <span class="k">branch</span>
444 </a>454 </a>
445 </li>455 </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">
447 <a href="/files/tree/v1.0" tabindex="-1">457 <a href="/files/tree/v1.0" tabindex="-1">
448 <span class="lbl">v1.0</span>458 <span class="lbl">v1.0</span>
449 <span class="k">tag</span>459 <span class="k">tag</span>
450 </a>460 </a>
451 </li>461 </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">
453 <a href="#" data-fire="keys" tabindex="-1">463 <a href="#" data-fire="keys" tabindex="-1">
454 <span class="lbl">turn single-key shortcuts off</span>464 <span class="lbl">turn single-key shortcuts off</span>
455 <span class="k">action</span>465 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -410,31 +414,37 @@
410 <span class="k">section</span>414 <span class="k">section</span>
411 </a>415 </a>
412 </li>416 </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">
414 <a href="/diffs/actions" tabindex="-1">424 <a href="/diffs/actions" tabindex="-1">
415 <span class="lbl">actions</span>425 <span class="lbl">actions</span>
416 <span class="k">section</span>426 <span class="k">section</span>
417 </a>427 </a>
418 </li>428 </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">
420 <a href="/diffs/tree/main" tabindex="-1">430 <a href="/diffs/tree/main" tabindex="-1">
421 <span class="lbl">main</span>431 <span class="lbl">main</span>
422 <span class="k">branch</span>432 <span class="k">branch</span>
423 </a>433 </a>
424 </li>434 </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">
426 <a href="/diffs/tree/side" tabindex="-1">436 <a href="/diffs/tree/side" tabindex="-1">
427 <span class="lbl">side</span>437 <span class="lbl">side</span>
428 <span class="k">branch</span>438 <span class="k">branch</span>
429 </a>439 </a>
430 </li>440 </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">
432 <a href="/diffs/tree/v1.0.0" tabindex="-1">442 <a href="/diffs/tree/v1.0.0" tabindex="-1">
433 <span class="lbl">v1.0.0</span>443 <span class="lbl">v1.0.0</span>
434 <span class="k">tag</span>444 <span class="k">tag</span>
435 </a>445 </a>
436 </li>446 </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">
438 <a href="#" data-fire="keys" tabindex="-1">448 <a href="#" data-fire="keys" tabindex="-1">
439 <span class="lbl">turn single-key shortcuts off</span>449 <span class="lbl">turn single-key shortcuts off</span>
440 <span class="k">action</span>450 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -401,31 +405,37 @@
401 <span class="k">section</span>405 <span class="k">section</span>
402 </a>406 </a>
403 </li>407 </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">
405 <a href="/diffs/actions" tabindex="-1">415 <a href="/diffs/actions" tabindex="-1">
406 <span class="lbl">actions</span>416 <span class="lbl">actions</span>
407 <span class="k">section</span>417 <span class="k">section</span>
408 </a>418 </a>
409 </li>419 </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">
411 <a href="/diffs/tree/main" tabindex="-1">421 <a href="/diffs/tree/main" tabindex="-1">
412 <span class="lbl">main</span>422 <span class="lbl">main</span>
413 <span class="k">branch</span>423 <span class="k">branch</span>
414 </a>424 </a>
415 </li>425 </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">
417 <a href="/diffs/tree/side" tabindex="-1">427 <a href="/diffs/tree/side" tabindex="-1">
418 <span class="lbl">side</span>428 <span class="lbl">side</span>
419 <span class="k">branch</span>429 <span class="k">branch</span>
420 </a>430 </a>
421 </li>431 </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">
423 <a href="/diffs/tree/v1.0.0" tabindex="-1">433 <a href="/diffs/tree/v1.0.0" tabindex="-1">
424 <span class="lbl">v1.0.0</span>434 <span class="lbl">v1.0.0</span>
425 <span class="k">tag</span>435 <span class="k">tag</span>
426 </a>436 </a>
427 </li>437 </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">
429 <a href="#" data-fire="keys" tabindex="-1">439 <a href="#" data-fire="keys" tabindex="-1">
430 <span class="lbl">turn single-key shortcuts off</span>440 <span class="lbl">turn single-key shortcuts off</span>
431 <span class="k">action</span>441 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -443,31 +447,37 @@
443 <span class="k">section</span>447 <span class="k">section</span>
444 </a>448 </a>
445 </li>449 </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">
447 <a href="/diffs/actions" tabindex="-1">457 <a href="/diffs/actions" tabindex="-1">
448 <span class="lbl">actions</span>458 <span class="lbl">actions</span>
449 <span class="k">section</span>459 <span class="k">section</span>
450 </a>460 </a>
451 </li>461 </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">
453 <a href="/diffs/tree/main" tabindex="-1">463 <a href="/diffs/tree/main" tabindex="-1">
454 <span class="lbl">main</span>464 <span class="lbl">main</span>
455 <span class="k">branch</span>465 <span class="k">branch</span>
456 </a>466 </a>
457 </li>467 </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">
459 <a href="/diffs/tree/side" tabindex="-1">469 <a href="/diffs/tree/side" tabindex="-1">
460 <span class="lbl">side</span>470 <span class="lbl">side</span>
461 <span class="k">branch</span>471 <span class="k">branch</span>
462 </a>472 </a>
463 </li>473 </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">
465 <a href="/diffs/tree/v1.0.0" tabindex="-1">475 <a href="/diffs/tree/v1.0.0" tabindex="-1">
466 <span class="lbl">v1.0.0</span>476 <span class="lbl">v1.0.0</span>
467 <span class="k">tag</span>477 <span class="k">tag</span>
468 </a>478 </a>
469 </li>479 </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">
471 <a href="#" data-fire="keys" tabindex="-1">481 <a href="#" data-fire="keys" tabindex="-1">
472 <span class="lbl">turn single-key shortcuts off</span>482 <span class="lbl">turn single-key shortcuts off</span>
473 <span class="k">action</span>483 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -566,31 +570,37 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566 <span class="k">section</span>570 <span class="k">section</span>
567 </a>571 </a>
568 </li>572 </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">
570 <a href="/diffs/actions" tabindex="-1">580 <a href="/diffs/actions" tabindex="-1">
571 <span class="lbl">actions</span>581 <span class="lbl">actions</span>
572 <span class="k">section</span>582 <span class="k">section</span>
573 </a>583 </a>
574 </li>584 </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">
576 <a href="/diffs/tree/main" tabindex="-1">586 <a href="/diffs/tree/main" tabindex="-1">
577 <span class="lbl">main</span>587 <span class="lbl">main</span>
578 <span class="k">branch</span>588 <span class="k">branch</span>
579 </a>589 </a>
580 </li>590 </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">
582 <a href="/diffs/tree/side" tabindex="-1">592 <a href="/diffs/tree/side" tabindex="-1">
583 <span class="lbl">side</span>593 <span class="lbl">side</span>
584 <span class="k">branch</span>594 <span class="k">branch</span>
585 </a>595 </a>
586 </li>596 </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">
588 <a href="/diffs/tree/v1.0.0" tabindex="-1">598 <a href="/diffs/tree/v1.0.0" tabindex="-1">
589 <span class="lbl">v1.0.0</span>599 <span class="lbl">v1.0.0</span>
590 <span class="k">tag</span>600 <span class="k">tag</span>
591 </a>601 </a>
592 </li>602 </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">
594 <a href="#" data-fire="keys" tabindex="-1">604 <a href="#" data-fire="keys" tabindex="-1">
595 <span class="lbl">turn single-key shortcuts off</span>605 <span class="lbl">turn single-key shortcuts off</span>
596 <span class="k">action</span>606 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -566,31 +570,37 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
566 <span class="k">section</span>570 <span class="k">section</span>
567 </a>571 </a>
568 </li>572 </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">
570 <a href="/diffs/actions" tabindex="-1">580 <a href="/diffs/actions" tabindex="-1">
571 <span class="lbl">actions</span>581 <span class="lbl">actions</span>
572 <span class="k">section</span>582 <span class="k">section</span>
573 </a>583 </a>
574 </li>584 </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">
576 <a href="/diffs/tree/main" tabindex="-1">586 <a href="/diffs/tree/main" tabindex="-1">
577 <span class="lbl">main</span>587 <span class="lbl">main</span>
578 <span class="k">branch</span>588 <span class="k">branch</span>
579 </a>589 </a>
580 </li>590 </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">
582 <a href="/diffs/tree/side" tabindex="-1">592 <a href="/diffs/tree/side" tabindex="-1">
583 <span class="lbl">side</span>593 <span class="lbl">side</span>
584 <span class="k">branch</span>594 <span class="k">branch</span>
585 </a>595 </a>
586 </li>596 </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">
588 <a href="/diffs/tree/v1.0.0" tabindex="-1">598 <a href="/diffs/tree/v1.0.0" tabindex="-1">
589 <span class="lbl">v1.0.0</span>599 <span class="lbl">v1.0.0</span>
590 <span class="k">tag</span>600 <span class="k">tag</span>
591 </a>601 </a>
592 </li>602 </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">
594 <a href="#" data-fire="keys" tabindex="-1">604 <a href="#" data-fire="keys" tabindex="-1">
595 <span class="lbl">turn single-key shortcuts off</span>605 <span class="lbl">turn single-key shortcuts off</span>
596 <span class="k">action</span>606 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -470,31 +474,37 @@
470 <span class="k">section</span>474 <span class="k">section</span>
471 </a>475 </a>
472 </li>476 </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">
474 <a href="/diffs/actions" tabindex="-1">484 <a href="/diffs/actions" tabindex="-1">
475 <span class="lbl">actions</span>485 <span class="lbl">actions</span>
476 <span class="k">section</span>486 <span class="k">section</span>
477 </a>487 </a>
478 </li>488 </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">
480 <a href="/diffs/tree/main" tabindex="-1">490 <a href="/diffs/tree/main" tabindex="-1">
481 <span class="lbl">main</span>491 <span class="lbl">main</span>
482 <span class="k">branch</span>492 <span class="k">branch</span>
483 </a>493 </a>
484 </li>494 </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">
486 <a href="/diffs/tree/side" tabindex="-1">496 <a href="/diffs/tree/side" tabindex="-1">
487 <span class="lbl">side</span>497 <span class="lbl">side</span>
488 <span class="k">branch</span>498 <span class="k">branch</span>
489 </a>499 </a>
490 </li>500 </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">
492 <a href="/diffs/tree/v1.0.0" tabindex="-1">502 <a href="/diffs/tree/v1.0.0" tabindex="-1">
493 <span class="lbl">v1.0.0</span>503 <span class="lbl">v1.0.0</span>
494 <span class="k">tag</span>504 <span class="k">tag</span>
495 </a>505 </a>
496 </li>506 </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">
498 <a href="#" data-fire="keys" tabindex="-1">508 <a href="#" data-fire="keys" tabindex="-1">
499 <span class="lbl">turn single-key shortcuts off</span>509 <span class="lbl">turn single-key shortcuts off</span>
500 <span class="k">action</span>510 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -789,31 +793,37 @@
789 <span class="k">section</span>793 <span class="k">section</span>
790 </a>794 </a>
791 </li>795 </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">
793 <a href="/diffs/actions" tabindex="-1">803 <a href="/diffs/actions" tabindex="-1">
794 <span class="lbl">actions</span>804 <span class="lbl">actions</span>
795 <span class="k">section</span>805 <span class="k">section</span>
796 </a>806 </a>
797 </li>807 </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">
799 <a href="/diffs/tree/main" tabindex="-1">809 <a href="/diffs/tree/main" tabindex="-1">
800 <span class="lbl">main</span>810 <span class="lbl">main</span>
801 <span class="k">branch</span>811 <span class="k">branch</span>
802 </a>812 </a>
803 </li>813 </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">
805 <a href="/diffs/tree/side" tabindex="-1">815 <a href="/diffs/tree/side" tabindex="-1">
806 <span class="lbl">side</span>816 <span class="lbl">side</span>
807 <span class="k">branch</span>817 <span class="k">branch</span>
808 </a>818 </a>
809 </li>819 </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">
811 <a href="/diffs/tree/v1.0.0" tabindex="-1">821 <a href="/diffs/tree/v1.0.0" tabindex="-1">
812 <span class="lbl">v1.0.0</span>822 <span class="lbl">v1.0.0</span>
813 <span class="k">tag</span>823 <span class="k">tag</span>
814 </a>824 </a>
815 </li>825 </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">
817 <a href="#" data-fire="keys" tabindex="-1">827 <a href="#" data-fire="keys" tabindex="-1">
818 <span class="lbl">turn single-key shortcuts off</span>828 <span class="lbl">turn single-key shortcuts off</span>
819 <span class="k">action</span>829 <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>
@@ -92,6 +92,10 @@
92 </a>92 </a>
93 </li>93 </li>
94 <li>94 <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>
95 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>99 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
96 </a>100 </a>
97 </li>101 </li>
@@ -618,31 +622,37 @@
618 <span class="k">section</span>622 <span class="k">section</span>
619 </a>623 </a>
620 </li>624 </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">
622 <a href="/diffs/actions" tabindex="-1">632 <a href="/diffs/actions" tabindex="-1">
623 <span class="lbl">actions</span>633 <span class="lbl">actions</span>
624 <span class="k">section</span>634 <span class="k">section</span>
625 </a>635 </a>
626 </li>636 </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">
628 <a href="/diffs/tree/main" tabindex="-1">638 <a href="/diffs/tree/main" tabindex="-1">
629 <span class="lbl">main</span>639 <span class="lbl">main</span>
630 <span class="k">branch</span>640 <span class="k">branch</span>
631 </a>641 </a>
632 </li>642 </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">
634 <a href="/diffs/tree/side" tabindex="-1">644 <a href="/diffs/tree/side" tabindex="-1">
635 <span class="lbl">side</span>645 <span class="lbl">side</span>
636 <span class="k">branch</span>646 <span class="k">branch</span>
637 </a>647 </a>
638 </li>648 </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">
640 <a href="/diffs/tree/v1.0.0" tabindex="-1">650 <a href="/diffs/tree/v1.0.0" tabindex="-1">
641 <span class="lbl">v1.0.0</span>651 <span class="lbl">v1.0.0</span>
642 <span class="k">tag</span>652 <span class="k">tag</span>
643 </a>653 </a>
644 </li>654 </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">
646 <a href="#" data-fire="keys" tabindex="-1">656 <a href="#" data-fire="keys" tabindex="-1">
647 <span class="lbl">turn single-key shortcuts off</span>657 <span class="lbl">turn single-key shortcuts off</span>
648 <span class="k">action</span>658 <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>
@@ -90,6 +90,10 @@
90 </a>90 </a>
91 </li>91 </li>
92 <li>92 <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>
93 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>97 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
94 </a>98 </a>
95 </li>99 </li>
@@ -377,25 +381,31 @@
377 <span class="k">section</span>381 <span class="k">section</span>
378 </a>382 </a>
379 </li>383 </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">
381 <a href="/long/actions" tabindex="-1">391 <a href="/long/actions" tabindex="-1">
382 <span class="lbl">actions</span>392 <span class="lbl">actions</span>
383 <span class="k">section</span>393 <span class="k">section</span>
384 </a>394 </a>
385 </li>395 </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">
387 <a href="/long/tree/main" tabindex="-1">397 <a href="/long/tree/main" tabindex="-1">
388 <span class="lbl">main</span>398 <span class="lbl">main</span>
389 <span class="k">branch</span>399 <span class="k">branch</span>
390 </a>400 </a>
391 </li>401 </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">
393 <a href="/diffs" tabindex="-1">403 <a href="/diffs" tabindex="-1">
394 <span class="lbl">diffs</span>404 <span class="lbl">diffs</span>
395 <span class="k">repo</span>405 <span class="k">repo</span>
396 </a>406 </a>
397 </li>407 </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">
399 <a href="#" data-fire="keys" tabindex="-1">409 <a href="#" data-fire="keys" tabindex="-1">
400 <span class="lbl">turn single-key shortcuts off</span>410 <span class="lbl">turn single-key shortcuts off</span>
401 <span class="k">action</span>411 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -392,25 +396,31 @@
392 <span class="k">section</span>396 <span class="k">section</span>
393 </a>397 </a>
394 </li>398 </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">
396 <a href="/long/actions" tabindex="-1">406 <a href="/long/actions" tabindex="-1">
397 <span class="lbl">actions</span>407 <span class="lbl">actions</span>
398 <span class="k">section</span>408 <span class="k">section</span>
399 </a>409 </a>
400 </li>410 </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">
402 <a href="/long/tree/main" tabindex="-1">412 <a href="/long/tree/main" tabindex="-1">
403 <span class="lbl">main</span>413 <span class="lbl">main</span>
404 <span class="k">branch</span>414 <span class="k">branch</span>
405 </a>415 </a>
406 </li>416 </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">
408 <a href="/diffs" tabindex="-1">418 <a href="/diffs" tabindex="-1">
409 <span class="lbl">diffs</span>419 <span class="lbl">diffs</span>
410 <span class="k">repo</span>420 <span class="k">repo</span>
411 </a>421 </a>
412 </li>422 </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">
414 <a href="#" data-fire="keys" tabindex="-1">424 <a href="#" data-fire="keys" tabindex="-1">
415 <span class="lbl">turn single-key shortcuts off</span>425 <span class="lbl">turn single-key shortcuts off</span>
416 <span class="k">action</span>426 <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>
@@ -90,6 +90,10 @@
90 </a>90 </a>
91 </li>91 </li>
92 <li>92 <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>
93 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>97 <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
94 </a>98 </a>
95 </li>99 </li>
@@ -1182,25 +1186,31 @@
1182 <span class="k">section</span>1186 <span class="k">section</span>
1183 </a>1187 </a>
1184 </li>1188 </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">
1186 <a href="/long/actions" tabindex="-1">1196 <a href="/long/actions" tabindex="-1">
1187 <span class="lbl">actions</span>1197 <span class="lbl">actions</span>
1188 <span class="k">section</span>1198 <span class="k">section</span>
1189 </a>1199 </a>
1190 </li>1200 </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">
1192 <a href="/long/tree/main" tabindex="-1">1202 <a href="/long/tree/main" tabindex="-1">
1193 <span class="lbl">main</span>1203 <span class="lbl">main</span>
1194 <span class="k">branch</span>1204 <span class="k">branch</span>
1195 </a>1205 </a>
1196 </li>1206 </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">
1198 <a href="/diffs" tabindex="-1">1208 <a href="/diffs" tabindex="-1">
1199 <span class="lbl">diffs</span>1209 <span class="lbl">diffs</span>
1200 <span class="k">repo</span>1210 <span class="k">repo</span>
1201 </a>1211 </a>
1202 </li>1212 </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">
1204 <a href="#" data-fire="keys" tabindex="-1">1214 <a href="#" data-fire="keys" tabindex="-1">
1205 <span class="lbl">turn single-key shortcuts off</span>1215 <span class="lbl">turn single-key shortcuts off</span>
1206 <span class="k">action</span>1216 <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>
@@ -90,6 +90,10 @@
90 </a>90 </a>
91 </li>91 </li>
92 <li>92 <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>
93 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>97 <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
94 </a>98 </a>
95 </li>99 </li>
@@ -535,37 +539,43 @@
535 <span class="k">section</span>539 <span class="k">section</span>
536 </a>540 </a>
537 </li>541 </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">
539 <a href="/diffs/actions" tabindex="-1">549 <a href="/diffs/actions" tabindex="-1">
540 <span class="lbl">actions</span>550 <span class="lbl">actions</span>
541 <span class="k">section</span>551 <span class="k">section</span>
542 </a>552 </a>
543 </li>553 </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">
545 <a href="/diffs/tree/main" tabindex="-1">555 <a href="/diffs/tree/main" tabindex="-1">
546 <span class="lbl">main</span>556 <span class="lbl">main</span>
547 <span class="k">branch</span>557 <span class="k">branch</span>
548 </a>558 </a>
549 </li>559 </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">
551 <a href="/diffs/tree/side" tabindex="-1">561 <a href="/diffs/tree/side" tabindex="-1">
552 <span class="lbl">side</span>562 <span class="lbl">side</span>
553 <span class="k">branch</span>563 <span class="k">branch</span>
554 </a>564 </a>
555 </li>565 </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">
557 <a href="/diffs/tree/v1.0.0" tabindex="-1">567 <a href="/diffs/tree/v1.0.0" tabindex="-1">
558 <span class="lbl">v1.0.0</span>568 <span class="lbl">v1.0.0</span>
559 <span class="k">tag</span>569 <span class="k">tag</span>
560 </a>570 </a>
561 </li>571 </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">
563 <a href="/long" tabindex="-1">573 <a href="/long" tabindex="-1">
564 <span class="lbl">long</span>574 <span class="lbl">long</span>
565 <span class="k">repo</span>575 <span class="k">repo</span>
566 </a>576 </a>
567 </li>577 </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">
569 <a href="#" data-fire="keys" tabindex="-1">579 <a href="#" data-fire="keys" tabindex="-1">
570 <span class="lbl">turn single-key shortcuts off</span>580 <span class="lbl">turn single-key shortcuts off</span>
571 <span class="k">action</span>581 <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>
@@ -87,6 +87,10 @@
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <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>
90 <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>94 <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
91 </a>95 </a>
92 </li>96 </li>
@@ -421,43 +425,49 @@
421 <span class="k">section</span>425 <span class="k">section</span>
422 </a>426 </a>
423 </li>427 </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">
425 <a href="/demo/actions" tabindex="-1">435 <a href="/demo/actions" tabindex="-1">
426 <span class="lbl">actions</span>436 <span class="lbl">actions</span>
427 <span class="k">section</span>437 <span class="k">section</span>
428 </a>438 </a>
429 </li>439 </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">
431 <a href="/demo/tree/feature/x" tabindex="-1">441 <a href="/demo/tree/feature/x" tabindex="-1">
432 <span class="lbl">feature/x</span>442 <span class="lbl">feature/x</span>
433 <span class="k">branch</span>443 <span class="k">branch</span>
434 </a>444 </a>
435 </li>445 </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">
437 <a href="/demo/tree/main" tabindex="-1">447 <a href="/demo/tree/main" tabindex="-1">
438 <span class="lbl">main</span>448 <span class="lbl">main</span>
439 <span class="k">branch</span>449 <span class="k">branch</span>
440 </a>450 </a>
441 </li>451 </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">
443 <a href="/demo/tree/v0.1.0" tabindex="-1">453 <a href="/demo/tree/v0.1.0" tabindex="-1">
444 <span class="lbl">v0.1.0</span>454 <span class="lbl">v0.1.0</span>
445 <span class="k">tag</span>455 <span class="k">tag</span>
446 </a>456 </a>
447 </li>457 </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">
449 <a href="/demo/tree/v0.2.0" tabindex="-1">459 <a href="/demo/tree/v0.2.0" tabindex="-1">
450 <span class="lbl">v0.2.0</span>460 <span class="lbl">v0.2.0</span>
451 <span class="k">tag</span>461 <span class="k">tag</span>
452 </a>462 </a>
453 </li>463 </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">
455 <a href="/quiet" tabindex="-1">465 <a href="/quiet" tabindex="-1">
456 <span class="lbl">quiet</span>466 <span class="lbl">quiet</span>
457 <span class="k">repo</span>467 <span class="k">repo</span>
458 </a>468 </a>
459 </li>469 </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">
461 <a href="#" data-fire="keys" tabindex="-1">471 <a href="#" data-fire="keys" tabindex="-1">
462 <span class="lbl">turn single-key shortcuts off</span>472 <span class="lbl">turn single-key shortcuts off</span>
463 <span class="k">action</span>473 <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>
@@ -84,6 +84,10 @@
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <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>
87 <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>91 <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
88 </a>92 </a>
89 </li>93 </li>
@@ -447,43 +451,49 @@
447 <span class="k">section</span>451 <span class="k">section</span>
448 </a>452 </a>
449 </li>453 </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">
451 <a href="/demo/actions" tabindex="-1">461 <a href="/demo/actions" tabindex="-1">
452 <span class="lbl">actions</span>462 <span class="lbl">actions</span>
453 <span class="k">section</span>463 <span class="k">section</span>
454 </a>464 </a>
455 </li>465 </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">
457 <a href="/demo/tree/feature/x" tabindex="-1">467 <a href="/demo/tree/feature/x" tabindex="-1">
458 <span class="lbl">feature/x</span>468 <span class="lbl">feature/x</span>
459 <span class="k">branch</span>469 <span class="k">branch</span>
460 </a>470 </a>
461 </li>471 </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">
463 <a href="/demo/tree/main" tabindex="-1">473 <a href="/demo/tree/main" tabindex="-1">
464 <span class="lbl">main</span>474 <span class="lbl">main</span>
465 <span class="k">branch</span>475 <span class="k">branch</span>
466 </a>476 </a>
467 </li>477 </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">
469 <a href="/demo/tree/v0.1.0" tabindex="-1">479 <a href="/demo/tree/v0.1.0" tabindex="-1">
470 <span class="lbl">v0.1.0</span>480 <span class="lbl">v0.1.0</span>
471 <span class="k">tag</span>481 <span class="k">tag</span>
472 </a>482 </a>
473 </li>483 </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">
475 <a href="/demo/tree/v0.2.0" tabindex="-1">485 <a href="/demo/tree/v0.2.0" tabindex="-1">
476 <span class="lbl">v0.2.0</span>486 <span class="lbl">v0.2.0</span>
477 <span class="k">tag</span>487 <span class="k">tag</span>
478 </a>488 </a>
479 </li>489 </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">
481 <a href="/quiet" tabindex="-1">491 <a href="/quiet" tabindex="-1">
482 <span class="lbl">quiet</span>492 <span class="lbl">quiet</span>
483 <span class="k">repo</span>493 <span class="k">repo</span>
484 </a>494 </a>
485 </li>495 </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">
487 <a href="#" data-fire="keys" tabindex="-1">497 <a href="#" data-fire="keys" tabindex="-1">
488 <span class="lbl">turn single-key shortcuts off</span>498 <span class="lbl">turn single-key shortcuts off</span>
489 <span class="k">action</span>499 <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>
@@ -96,6 +96,10 @@
96 </a>96 </a>
97 </li>97 </li>
98 <li>98 <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>
99 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>103 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100 </a>104 </a>
101 </li>105 </li>
@@ -373,31 +377,37 @@
373 <span class="k">section</span>377 <span class="k">section</span>
374 </a>378 </a>
375 </li>379 </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">
377 <a href="/files/actions" tabindex="-1">387 <a href="/files/actions" tabindex="-1">
378 <span class="lbl">actions</span>388 <span class="lbl">actions</span>
379 <span class="k">section</span>389 <span class="k">section</span>
380 </a>390 </a>
381 </li>391 </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">
383 <a href="/files/tree/feature/x" tabindex="-1">393 <a href="/files/tree/feature/x" tabindex="-1">
384 <span class="lbl">feature/x</span>394 <span class="lbl">feature/x</span>
385 <span class="k">branch</span>395 <span class="k">branch</span>
386 </a>396 </a>
387 </li>397 </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">
389 <a href="/files/tree/main" tabindex="-1">399 <a href="/files/tree/main" tabindex="-1">
390 <span class="lbl">main</span>400 <span class="lbl">main</span>
391 <span class="k">branch</span>401 <span class="k">branch</span>
392 </a>402 </a>
393 </li>403 </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">
395 <a href="/files/tree/v1.0" tabindex="-1">405 <a href="/files/tree/v1.0" tabindex="-1">
396 <span class="lbl">v1.0</span>406 <span class="lbl">v1.0</span>
397 <span class="k">tag</span>407 <span class="k">tag</span>
398 </a>408 </a>
399 </li>409 </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">
401 <a href="#" data-fire="keys" tabindex="-1">411 <a href="#" data-fire="keys" tabindex="-1">
402 <span class="lbl">turn single-key shortcuts off</span>412 <span class="lbl">turn single-key shortcuts off</span>
403 <span class="k">action</span>413 <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>
@@ -90,6 +90,10 @@
90 </a>90 </a>
91 </li>91 </li>
92 <li>92 <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>
93 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>97 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
94 </a>98 </a>
95 </li>99 </li>
@@ -594,31 +598,37 @@
594 <span class="k">section</span>598 <span class="k">section</span>
595 </a>599 </a>
596 </li>600 </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">
598 <a href="/files/actions" tabindex="-1">608 <a href="/files/actions" tabindex="-1">
599 <span class="lbl">actions</span>609 <span class="lbl">actions</span>
600 <span class="k">section</span>610 <span class="k">section</span>
601 </a>611 </a>
602 </li>612 </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">
604 <a href="/files/tree/feature/x" tabindex="-1">614 <a href="/files/tree/feature/x" tabindex="-1">
605 <span class="lbl">feature/x</span>615 <span class="lbl">feature/x</span>
606 <span class="k">branch</span>616 <span class="k">branch</span>
607 </a>617 </a>
608 </li>618 </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">
610 <a href="/files/tree/main" tabindex="-1">620 <a href="/files/tree/main" tabindex="-1">
611 <span class="lbl">main</span>621 <span class="lbl">main</span>
612 <span class="k">branch</span>622 <span class="k">branch</span>
613 </a>623 </a>
614 </li>624 </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">
616 <a href="/files/tree/v1.0" tabindex="-1">626 <a href="/files/tree/v1.0" tabindex="-1">
617 <span class="lbl">v1.0</span>627 <span class="lbl">v1.0</span>
618 <span class="k">tag</span>628 <span class="k">tag</span>
619 </a>629 </a>
620 </li>630 </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">
622 <a href="#" data-fire="keys" tabindex="-1">632 <a href="#" data-fire="keys" tabindex="-1">
623 <span class="lbl">turn single-key shortcuts off</span>633 <span class="lbl">turn single-key shortcuts off</span>
624 <span class="k">action</span>634 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -370,31 +374,37 @@
370 <span class="k">section</span>374 <span class="k">section</span>
371 </a>375 </a>
372 </li>376 </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">
374 <a href="/files/actions" tabindex="-1">384 <a href="/files/actions" tabindex="-1">
375 <span class="lbl">actions</span>385 <span class="lbl">actions</span>
376 <span class="k">section</span>386 <span class="k">section</span>
377 </a>387 </a>
378 </li>388 </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">
380 <a href="/files/tree/feature/x" tabindex="-1">390 <a href="/files/tree/feature/x" tabindex="-1">
381 <span class="lbl">feature/x</span>391 <span class="lbl">feature/x</span>
382 <span class="k">branch</span>392 <span class="k">branch</span>
383 </a>393 </a>
384 </li>394 </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">
386 <a href="/files/tree/main" tabindex="-1">396 <a href="/files/tree/main" tabindex="-1">
387 <span class="lbl">main</span>397 <span class="lbl">main</span>
388 <span class="k">branch</span>398 <span class="k">branch</span>
389 </a>399 </a>
390 </li>400 </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">
392 <a href="/files/tree/v1.0" tabindex="-1">402 <a href="/files/tree/v1.0" tabindex="-1">
393 <span class="lbl">v1.0</span>403 <span class="lbl">v1.0</span>
394 <span class="k">tag</span>404 <span class="k">tag</span>
395 </a>405 </a>
396 </li>406 </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">
398 <a href="#" data-fire="keys" tabindex="-1">408 <a href="#" data-fire="keys" tabindex="-1">
399 <span class="lbl">turn single-key shortcuts off</span>409 <span class="lbl">turn single-key shortcuts off</span>
400 <span class="k">action</span>410 <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>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <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>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -386,31 +390,37 @@
386 <span class="k">section</span>390 <span class="k">section</span>
387 </a>391 </a>
388 </li>392 </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">
390 <a href="/files/actions" tabindex="-1">400 <a href="/files/actions" tabindex="-1">
391 <span class="lbl">actions</span>401 <span class="lbl">actions</span>
392 <span class="k">section</span>402 <span class="k">section</span>
393 </a>403 </a>
394 </li>404 </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">
396 <a href="/files/tree/feature/x" tabindex="-1">406 <a href="/files/tree/feature/x" tabindex="-1">
397 <span class="lbl">feature/x</span>407 <span class="lbl">feature/x</span>
398 <span class="k">branch</span>408 <span class="k">branch</span>
399 </a>409 </a>
400 </li>410 </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">
402 <a href="/files/tree/main" tabindex="-1">412 <a href="/files/tree/main" tabindex="-1">
403 <span class="lbl">main</span>413 <span class="lbl">main</span>
404 <span class="k">branch</span>414 <span class="k">branch</span>
405 </a>415 </a>
406 </li>416 </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">
408 <a href="/files/tree/v1.0" tabindex="-1">418 <a href="/files/tree/v1.0" tabindex="-1">
409 <span class="lbl">v1.0</span>419 <span class="lbl">v1.0</span>
410 <span class="k">tag</span>420 <span class="k">tag</span>
411 </a>421 </a>
412 </li>422 </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">
414 <a href="#" data-fire="keys" tabindex="-1">424 <a href="#" data-fire="keys" tabindex="-1">
415 <span class="lbl">turn single-key shortcuts off</span>425 <span class="lbl">turn single-key shortcuts off</span>
416 <span class="k">action</span>426 <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,
@@ -57,6 +57,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
57 SigningKey: signing,57 SigningKey: signing,
58 Runs: f.CI,58 Runs: f.CI,
59 Artifacts: artifacts,59 Artifacts: artifacts,
60+ Releases: f.Releases,
60 Events: f.EventLog,61 Events: f.EventLog,
61 Mirrors: f.Mirrors,62 Mirrors: f.Mirrors,
62 Owner: cfg.Owner,63 Owner: cfg.Owner,
M internal/app/web_release_test.go +3 -12
@@ -6,7 +6,6 @@ import (
66 "context"
77 "io"
88 "net/http"
9- "net/http/httptest"
109 "strconv"
1110 "strings"
1211 "testing"
@@ -14,20 +13,15 @@ import (
1413 "github.com/stretchr/testify/assert"
1514 "github.com/stretchr/testify/require"
1615
17- "bnema.dev/ermite/internal/adapters/blobdisk"
18- "bnema.dev/ermite/internal/adapters/web"
1916 "bnema.dev/ermite/internal/domain"
2017 "bnema.dev/ermite/internal/ports"
21- "bnema.dev/ermite/internal/usecase"
2218 )
2319
24-// P8 owns production wiring. Until then, this test wires the real release
25-// use case, forge metadata/Git, disk blobs and web in a test-local server.
20+// Releases and web use the production forge wiring.
2621 func TestWebRelease(t *testing.T) {
2722 h := newHarness(t, harnessOpts{})
2823 h.webRepo("demo")
29- blobs := blobdisk.New(t.TempDir())
30- releases := usecase.NewReleases(h.forge.store.Releases(), blobs, h.forge.store, h.forge.git, h.forge.git, h.forge.Pushes, h.forge.Bus, systemClock{}, ports.ReleaseLimits{})
24+ blobs, releases := h.forge.Blobs, h.forge.Releases
3125 repo := h.name("demo")
3226 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>"})
3327 require.NoError(t, err)
@@ -38,10 +32,7 @@ func TestWebRelease(t *testing.T) {
3832 require.NoError(t, err)
3933 draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))
4034 require.NoError(t, err)
41- srv, err := web.New(web.Config{Repos: h.forge.Repos, Browse: h.forge.Browse, Releases: releases, PublicURL: "http://example.test"})
42- require.NoError(t, err)
43- ts := httptest.NewServer(srv)
44- t.Cleanup(ts.Close)
35+ ts := h.srv
4536 get := func(method, path, byteRange string) (int, http.Header, string) {
4637 t.Helper()
4738 req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)
@@ -6,7 +6,6 @@ import (
6 "context"6 "context"
7 "io"7 "io"
8 "net/http"8 "net/http"
9- "net/http/httptest"
10 "strconv"9 "strconv"
11 "strings"10 "strings"
12 "testing"11 "testing"
@@ -14,20 +13,15 @@ import (
14 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
16 15
17- "bnema.dev/ermite/internal/adapters/blobdisk"
18- "bnema.dev/ermite/internal/adapters/web"
19 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
21- "bnema.dev/ermite/internal/usecase"
22 )18 )
23 19
24-// P8 owns production wiring. Until then, this test wires the real release20+// Releases and web use the production forge wiring.
25-// use case, forge metadata/Git, disk blobs and web in a test-local server.
26 func TestWebRelease(t *testing.T) {21 func TestWebRelease(t *testing.T) {
27 h := newHarness(t, harnessOpts{})22 h := newHarness(t, harnessOpts{})
28 h.webRepo("demo")23 h.webRepo("demo")
29- blobs := blobdisk.New(t.TempDir())24+ blobs, releases := h.forge.Blobs, h.forge.Releases
30- releases := usecase.NewReleases(h.forge.store.Releases(), blobs, h.forge.store, h.forge.git, h.forge.git, h.forge.Pushes, h.forge.Bus, systemClock{}, ports.ReleaseLimits{})
31 repo := h.name("demo")25 repo := h.name("demo")
32 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>"})26 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>"})
33 require.NoError(t, err)27 require.NoError(t, err)
@@ -38,10 +32,7 @@ func TestWebRelease(t *testing.T) {
38 require.NoError(t, err)32 require.NoError(t, err)
39 draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))33 draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))
40 require.NoError(t, err)34 require.NoError(t, err)
41- srv, err := web.New(web.Config{Repos: h.forge.Repos, Browse: h.forge.Browse, Releases: releases, PublicURL: "http://example.test"})35+ ts := h.srv
42- require.NoError(t, err)
43- ts := httptest.NewServer(srv)
44- t.Cleanup(ts.Close)
45 get := func(method, path, byteRange string) (int, http.Header, string) {36 get := func(method, path, byteRange string) (int, http.Header, string) {
46 t.Helper()37 t.Helper()
47 req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)38 req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)
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
@@ -12,6 +12,7 @@ import (
12 "net"12 "net"
13 "net/netip"13 "net/netip"
14 "net/url"14 "net/url"
15+ "os"
15 "path/filepath"16 "path/filepath"
16 "strconv"17 "strconv"
17 "strings"18 "strings"
@@ -56,6 +57,16 @@ const (
56 EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION"57 EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION"
57 EnvCIArtifactQuota = "ERMITE_CI_ARTIFACT_QUOTA"58 EnvCIArtifactQuota = "ERMITE_CI_ARTIFACT_QUOTA"
58 EnvCIArtifactMax = "ERMITE_CI_ARTIFACT_MAX"59 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"
59 )70 )
60 71
61 // SecretsKeyLen is the length of the secrets key in bytes (AES-256).72 // SecretsKeyLen is the length of the secrets key in bytes (AES-256).
@@ -140,6 +151,12 @@ type Config struct {
140 CIArtifactRetention time.Duration151 CIArtifactRetention time.Duration
141 CIArtifactQuota int64152 CIArtifactQuota int64
142 CIArtifactMax int64153 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
143 }160 }
144 161
145 // Error lists every configuration problem found, in variable order.162 // Error lists every configuration problem found, in variable order.
@@ -167,6 +184,7 @@ func Load(get func(string) string) (Config, error) {
167 bad := func(key, format string, args ...any) {184 bad := func(key, format string, args ...any) {
168 problems = append(problems, key+": "+fmt.Sprintf(format, args...))185 problems = append(problems, key+": "+fmt.Sprintf(format, args...))
169 }186 }
187+ get = resolveFiles(get, &problems)
170 var c Config188 var c Config
171 189
172 c.Addr = get(EnvAddr)190 c.Addr = get(EnvAddr)
@@ -342,12 +360,111 @@ func Load(get func(string) string) (Config, error) {
342 }360 }
343 }361 }
344 362
363+ loadStorage(get, &c, &problems)
345 if len(problems) > 0 {364 if len(problems) > 0 {
346 return Config{}, &Error{Problems: problems}365 return Config{}, &Error{Problems: problems}
347 }366 }
348 return c, nil367 return c, nil
349 }368 }
350 369
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+
351 // sizeUnits are the suffixes of parseSize, longest first.468 // sizeUnits are the suffixes of parseSize, longest first.
352 var sizeUnits = []struct {469 var sizeUnits = []struct {
353 suffix string470 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+}
@@ -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 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] [--json]` | 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] [--json]` | 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 |
@@ -44,6 +44,7 @@ Scopes:
44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45 | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |45 | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
46 | `issue:write` | open, comment on, close and reopen issues |46 | `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 |
47 | `token:admin` | create, list and revoke tokens |48 | `token:admin` | create, list and revoke tokens |
48 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |49 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
49 50
@@ -93,6 +94,25 @@ Issues share the numbering of merge requests and must be enabled per repository:
93 94
94 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`.95 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`.
95 96
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] [--json]` | 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] [--json]` | 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+
96 ## Workflows116 ## Workflows
97 117
98 | Command | Scope | Notes |118 | 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 41

↑↓ moveenter openesc close