Skip to content

Commit 32bd8cb

Browse files
JRemitzclaude
andcommitted
feat: v0.2.0 — post-game R2 cleanup, CI/CD pipelines
Add ON_POST_GAME_FINISH hook handler that deletes uploaded R2 objects after downstream plugins finish, gated by cleanup_after_game feature flag. Track upload keys across POST_RENDER calls for cleanup. Add GitHub Actions CI (Python 3.11/3.12/3.13 matrix) and tag-triggered release workflow with OIDC PyPI publishing. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b609999 commit 32bd8cb

10 files changed

Lines changed: 528 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.11", "3.12", "3.13"]
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: astral-sh/setup-uv@v5
20+
with:
21+
enable-cache: true
22+
23+
- name: Set up Python ${{ matrix.python-version }}
24+
run: uv python install ${{ matrix.python-version }}
25+
26+
- name: Create venv
27+
run: uv venv --python ${{ matrix.python-version }}
28+
29+
- name: Install reeln-cli
30+
run: uv pip install "git+https://github.com/StreamnDad/reeln-cli"
31+
32+
- name: Install dependencies
33+
run: uv pip install -e ".[dev]"
34+
35+
- name: Lint
36+
run: uv run ruff check .
37+
38+
- name: Type check
39+
run: uv run mypy reeln_cloudflare_plugin/
40+
41+
- name: Test
42+
run: uv run python -m pytest tests/ -n auto --cov=reeln_cloudflare_plugin --cov-branch --cov-fail-under=100 -q

.github/workflows/release.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
jobs:
9+
publish:
10+
runs-on: ubuntu-latest
11+
environment: release
12+
permissions:
13+
id-token: write
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: astral-sh/setup-uv@v5
19+
with:
20+
enable-cache: true
21+
22+
- name: Set up Python
23+
run: uv python install 3.11
24+
25+
- name: Create venv
26+
run: uv venv --python 3.11
27+
28+
- name: Install reeln-cli
29+
run: uv pip install "git+https://github.com/StreamnDad/reeln-cli"
30+
31+
- name: Install dependencies
32+
run: uv pip install -e ".[dev]"
33+
34+
- name: Lint
35+
run: uv run ruff check .
36+
37+
- name: Type check
38+
run: uv run mypy reeln_cloudflare_plugin/
39+
40+
- name: Test
41+
run: uv run python -m pytest tests/ -n auto --cov=reeln_cloudflare_plugin --cov-branch --cov-fail-under=100 -q
42+
43+
- name: Build
44+
run: uv build
45+
46+
- name: Publish to PyPI
47+
uses: pypa/gh-action-pypi-publish@release/v1

CHANGELOG.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,24 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/).
66

7-
## [0.1.0] - 2026-03-24
7+
## [0.2.0] - 2026-03-25
88

99
### Added
1010

1111
- Initial plugin scaffolding with `CloudflarePlugin` class
12-
- `r2.py` module — Cloudflare R2 upload and object existence check via boto3 S3-compatible API
12+
- `r2.py` module — Cloudflare R2 upload, delete, and object existence check via boto3 S3-compatible API
1313
- `R2Config` frozen dataclass with endpoint, bucket, credentials, public URL base, region, and bandwidth throttle
1414
- `upload_file()` — uploads local file to R2, returns public CDN URL
15+
- `delete_object()` — deletes an object from the R2 bucket
1516
- `object_exists()` — checks if an object key exists in the bucket
1617
- `upload_video` feature flag — enables video upload on `POST_RENDER` hook
17-
- `dry_run` config field — logs upload actions without executing them
18+
- `cleanup_after_game` feature flag — deletes uploaded R2 objects on `ON_POST_GAME_FINISH`
19+
- `dry_run` config field — logs upload and delete actions without executing them
1820
- Environment variable-based credential resolution (config stores env var names, not secrets)
1921
- Bandwidth throttle support via `upload_max_kbps` config field
2022
- Custom key prefix support via `upload_prefix` config field
2123
- Shared context output: `context.shared["video_url"]` = public CDN URL
22-
- `ON_GAME_FINISH` hook handler — forward-compatible cleanup hook
24+
- Upload key tracking across `POST_RENDER` calls for post-game cleanup
25+
- `ON_GAME_FINISH` hook handler — resets uploaded keys list
26+
- `ON_POST_GAME_FINISH` hook handler — cleans up temporary R2 uploads after all downstream plugins finish
2327
- 100% line + branch test coverage

README.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,82 @@
11
# reeln-plugin-cloudflare
22

3-
reeln-cli plugin for Cloudflare R2 video uploads.
3+
[reeln-cli](https://github.com/StreamnDad/reeln-cli) plugin for uploading rendered videos to Cloudflare R2 storage.
4+
5+
Subscribes to `POST_RENDER` to upload the rendered video file and writes the public CDN URL to `context.shared["video_url"]` for downstream plugins (e.g. [reeln-plugin-meta](https://github.com/StreamnDad/reeln-plugin-meta) Instagram Reels publishing).
6+
7+
## Installation
8+
9+
```bash
10+
uv pip install reeln-plugin-cloudflare
11+
```
12+
13+
Or install editable for development:
14+
15+
```bash
16+
make dev-install
17+
```
18+
19+
## Configuration
20+
21+
<!-- AUTO-GENERATED: config-fields -->
22+
23+
| Field | Type | Default | Required | Description |
24+
|-------|------|---------|----------|-------------|
25+
| `r2_endpoint` | str || yes | Cloudflare R2 S3-compatible endpoint URL |
26+
| `r2_bucket` | str || yes | R2 bucket name |
27+
| `r2_access_key_env` | str || yes | Environment variable name containing the R2 access key ID |
28+
| `r2_secret_key_env` | str || yes | Environment variable name containing the R2 secret access key |
29+
| `public_url_base` | str || yes | Public CDN base URL for uploaded objects |
30+
| `upload_video` | bool | `false` | no | Enable video upload to R2 on POST\_RENDER |
31+
| `upload_prefix` | str | `""` | no | Optional key prefix (folder) for uploaded objects |
32+
| `upload_max_kbps` | int | `0` | no | Max upload bandwidth in KB/s (0 = unlimited) |
33+
| `dry_run` | bool | `false` | no | Log upload actions without executing them |
34+
| `r2_region` | str | `"auto"` | no | R2 region (usually 'auto') |
35+
36+
<!-- AUTO-GENERATED: /config-fields -->
37+
38+
### Example
39+
40+
In your reeln-cli `config.json`, list `cloudflare` **before** any plugin that consumes `video_url` (e.g. `meta`):
41+
42+
```json
43+
{
44+
"enabled": ["streamn-scoreboard", "openai", "cloudflare", "meta"],
45+
"cloudflare": {
46+
"r2_endpoint": "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
47+
"r2_bucket": "reeln-videos",
48+
"r2_access_key_env": "R2_ACCESS_KEY_ID",
49+
"r2_secret_key_env": "R2_SECRET_ACCESS_KEY",
50+
"public_url_base": "https://cdn.example.com",
51+
"upload_video": true,
52+
"upload_prefix": "reels"
53+
}
54+
}
55+
```
56+
57+
### Environment Variables
58+
59+
Credentials are resolved indirectly — the config stores the **name** of the environment variable, not the secret itself:
60+
61+
| Env Var (default name) | Description |
62+
|------------------------|-------------|
63+
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key ID |
64+
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret access key |
65+
66+
<!-- AUTO-GENERATED: dev-commands -->
67+
68+
## Development
69+
70+
| Command | Description |
71+
|---------|-------------|
72+
| `make dev-install` | Create venv, install reeln-cli + plugin with dev deps |
73+
| `make reeln-install` | Install plugin editable into sibling reeln-cli venv |
74+
| `make test` | Run pytest with 100% line+branch coverage (parallel via xdist) |
75+
| `make lint` | Run ruff linter |
76+
| `make format` | Run ruff formatter |
77+
| `make check` | Lint, mypy strict, then test (sequential) |
78+
79+
<!-- AUTO-GENERATED: /dev-commands -->
480

581
## License
682

reeln_cloudflare_plugin/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
__version__ = "0.1.0"
5+
__version__ = "0.2.0"
66

77
from reeln_cloudflare_plugin.plugin import CloudflarePlugin
88

reeln_cloudflare_plugin/plugin.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class CloudflarePlugin:
2424
"""
2525

2626
name: str = "cloudflare"
27-
version: str = "0.1.0"
27+
version: str = "0.2.0"
2828
api_version: int = 1
2929

3030
config_schema: PluginConfigSchema = PluginConfigSchema(
@@ -77,6 +77,12 @@ class CloudflarePlugin:
7777
default=0,
7878
description="Max upload bandwidth in KB/s (0 = unlimited)",
7979
),
80+
ConfigField(
81+
name="cleanup_after_game",
82+
field_type="bool",
83+
default=False,
84+
description="Delete uploaded R2 objects on ON_POST_GAME_FINISH",
85+
),
8086
ConfigField(
8187
name="dry_run",
8288
field_type="bool",
@@ -94,11 +100,13 @@ class CloudflarePlugin:
94100

95101
def __init__(self, config: dict[str, Any] | None = None) -> None:
96102
self._config: dict[str, Any] = config or {}
103+
self._uploaded_keys: list[str] = []
97104

98105
def register(self, registry: HookRegistry) -> None:
99106
"""Register hook handlers with the reeln plugin registry."""
100107
registry.register(Hook.POST_RENDER, self.on_post_render)
101108
registry.register(Hook.ON_GAME_FINISH, self.on_game_finish)
109+
registry.register(Hook.ON_POST_GAME_FINISH, self.on_post_game_finish)
102110

103111
def on_post_render(self, context: HookContext) -> None:
104112
"""Handle ``POST_RENDER`` — upload rendered video to R2."""
@@ -153,6 +161,7 @@ def on_post_render(self, context: HookContext) -> None:
153161
log.warning("Cloudflare plugin: upload failed (non-fatal): %s", exc)
154162
return
155163

164+
self._uploaded_keys.append(key)
156165
context.shared["video_url"] = public_url
157166
log.info("Cloudflare plugin: uploaded %s → %s", output, public_url)
158167

@@ -187,4 +196,52 @@ def _resolve_credentials(self) -> tuple[str, str] | None:
187196
return access_key_id, secret_access_key
188197

189198
def on_game_finish(self, context: HookContext) -> None:
190-
"""Handle ``ON_GAME_FINISH`` — forward-compatible cleanup hook."""
199+
"""Handle ``ON_GAME_FINISH`` — reset uploaded keys list."""
200+
self._uploaded_keys = []
201+
202+
def on_post_game_finish(self, context: HookContext) -> None:
203+
"""Handle ``ON_POST_GAME_FINISH`` — delete uploaded R2 objects."""
204+
if not self._config.get("cleanup_after_game"):
205+
return
206+
207+
if not self._uploaded_keys:
208+
return
209+
210+
credentials = self._resolve_credentials()
211+
if credentials is None:
212+
log.warning(
213+
"Cloudflare plugin: cannot cleanup R2 objects — credentials "
214+
"unavailable"
215+
)
216+
return
217+
218+
access_key_id, secret_access_key = credentials
219+
220+
config = r2.R2Config(
221+
endpoint=self._config.get("r2_endpoint", ""),
222+
bucket=self._config.get("r2_bucket", ""),
223+
access_key_id=access_key_id,
224+
secret_access_key=secret_access_key,
225+
public_url_base=self._config.get("public_url_base", ""),
226+
region=self._config.get("r2_region", "auto"),
227+
)
228+
229+
if self._config.get("dry_run"):
230+
for key in self._uploaded_keys:
231+
log.info(
232+
"Cloudflare plugin: [DRY RUN] would delete R2 object: %s",
233+
key,
234+
)
235+
return
236+
237+
for key in self._uploaded_keys:
238+
try:
239+
r2.delete_object(config, key)
240+
log.info("Cloudflare plugin: deleted R2 object: %s", key)
241+
except r2.R2Error as exc:
242+
log.warning(
243+
"Cloudflare plugin: failed to delete R2 object %s "
244+
"(non-fatal): %s",
245+
key,
246+
exc,
247+
)

reeln_cloudflare_plugin/r2.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ def upload_file(config: R2Config, source: Path, key: str) -> str:
8181
return f"{base}/{key}"
8282

8383

84+
def delete_object(config: R2Config, key: str) -> None:
85+
"""Delete an object from the R2 bucket.
86+
87+
Args:
88+
config: R2 connection configuration.
89+
key: Object key to delete.
90+
91+
Raises:
92+
R2Error: If client creation or deletion fails.
93+
"""
94+
try:
95+
s3 = _create_client(config)
96+
except Exception as exc:
97+
raise R2Error(f"Failed to create R2 client: {exc}") from exc
98+
99+
try:
100+
s3.delete_object(Bucket=config.bucket, Key=key)
101+
except Exception as exc:
102+
raise R2Error(f"R2 delete failed: {exc}") from exc
103+
104+
84105
def object_exists(config: R2Config, key: str) -> bool:
85106
"""Check whether an object exists in the R2 bucket.
86107

tests/unit/test_init.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
class TestPackageExports:
1010
def test_version_string(self) -> None:
1111
assert isinstance(reeln_cloudflare_plugin.__version__, str)
12-
assert reeln_cloudflare_plugin.__version__ == "0.1.0"
12+
assert reeln_cloudflare_plugin.__version__ == "0.2.0"
1313

1414
def test_cloudflare_plugin_export(self) -> None:
1515
assert hasattr(reeln_cloudflare_plugin, "CloudflarePlugin")

0 commit comments

Comments
 (0)