cassetta 0.30.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cassetta-0.30.0/.dockerignore +51 -0
- cassetta-0.30.0/.env.example +131 -0
- cassetta-0.30.0/.github/workflows/ci.yml +66 -0
- cassetta-0.30.0/.github/workflows/release.yml +85 -0
- cassetta-0.30.0/.github/workflows/smoke.yml +132 -0
- cassetta-0.30.0/.github/workflows/tag.yml +132 -0
- cassetta-0.30.0/.gitignore +41 -0
- cassetta-0.30.0/CHANGELOG.md +592 -0
- cassetta-0.30.0/CLA.md +125 -0
- cassetta-0.30.0/CONTRIBUTING.md +115 -0
- cassetta-0.30.0/Dockerfile +94 -0
- cassetta-0.30.0/LICENSE +105 -0
- cassetta-0.30.0/Makefile +80 -0
- cassetta-0.30.0/PKG-INFO +435 -0
- cassetta-0.30.0/README.md +384 -0
- cassetta-0.30.0/SECURITY.md +55 -0
- cassetta-0.30.0/docker-compose.yml +39 -0
- cassetta-0.30.0/docs/AGENT_SETUP.md +461 -0
- cassetta-0.30.0/docs/CLIENT_SETUP.md +605 -0
- cassetta-0.30.0/docs/CONFIG.md +201 -0
- cassetta-0.30.0/docs/GLOSSARY.md +51 -0
- cassetta-0.30.0/docs/LICENSE_FAQ.md +65 -0
- cassetta-0.30.0/docs/REST_API.md +289 -0
- cassetta-0.30.0/docs/adr/001-three-layer-architecture.md +53 -0
- cassetta-0.30.0/docs/adr/002-backendconfig-public-api.md +50 -0
- cassetta-0.30.0/docs/adr/003-protocol-self-identification.md +49 -0
- cassetta-0.30.0/docs/adr/004-configuration-as-a-layer-1-protocol.md +91 -0
- cassetta-0.30.0/pyproject.toml +211 -0
- cassetta-0.30.0/scripts/docker-entrypoint.sh +147 -0
- cassetta-0.30.0/scripts/smoke.sh +384 -0
- cassetta-0.30.0/scripts/sync-docs-version.py +184 -0
- cassetta-0.30.0/src/cassetta/__init__.py +16 -0
- cassetta-0.30.0/src/cassetta/app.py +392 -0
- cassetta-0.30.0/src/cassetta/auth/__init__.py +52 -0
- cassetta-0.30.0/src/cassetta/auth/dependencies.py +146 -0
- cassetta-0.30.0/src/cassetta/auth/jwt_hot_reload.py +264 -0
- cassetta-0.30.0/src/cassetta/auth/jwt_tokens.py +147 -0
- cassetta-0.30.0/src/cassetta/auth/manifest_validation.py +74 -0
- cassetta-0.30.0/src/cassetta/auth/models.py +28 -0
- cassetta-0.30.0/src/cassetta/auth/observability.py +81 -0
- cassetta-0.30.0/src/cassetta/backends/__init__.py +0 -0
- cassetta-0.30.0/src/cassetta/backends/filesystem/__init__.py +0 -0
- cassetta-0.30.0/src/cassetta/backends/filesystem/claim_storage.py +215 -0
- cassetta-0.30.0/src/cassetta/backends/filesystem/keystore.py +157 -0
- cassetta-0.30.0/src/cassetta/backends/filesystem/storage.py +399 -0
- cassetta-0.30.0/src/cassetta/capabilities.py +76 -0
- cassetta-0.30.0/src/cassetta/claims.py +73 -0
- cassetta-0.30.0/src/cassetta/cli/__init__.py +32 -0
- cassetta-0.30.0/src/cassetta/cli/capabilities.py +107 -0
- cassetta-0.30.0/src/cassetta/cli/download.py +191 -0
- cassetta-0.30.0/src/cassetta/cli/send.py +154 -0
- cassetta-0.30.0/src/cassetta/cli/upload.py +125 -0
- cassetta-0.30.0/src/cassetta/config.py +381 -0
- cassetta-0.30.0/src/cassetta/defaults/__init__.py +0 -0
- cassetta-0.30.0/src/cassetta/defaults/default_access.py +33 -0
- cassetta-0.30.0/src/cassetta/defaults/default_alias.py +40 -0
- cassetta-0.30.0/src/cassetta/defaults/default_identity.py +21 -0
- cassetta-0.30.0/src/cassetta/defaults/default_limits.py +149 -0
- cassetta-0.30.0/src/cassetta/defaults/default_metrics.py +40 -0
- cassetta-0.30.0/src/cassetta/defaults/default_transport.py +37 -0
- cassetta-0.30.0/src/cassetta/defaults/factory.py +74 -0
- cassetta-0.30.0/src/cassetta/dependencies.py +42 -0
- cassetta-0.30.0/src/cassetta/downloads.py +212 -0
- cassetta-0.30.0/src/cassetta/envelopes.py +173 -0
- cassetta-0.30.0/src/cassetta/gc.py +282 -0
- cassetta-0.30.0/src/cassetta/limits.py +100 -0
- cassetta-0.30.0/src/cassetta/mcp_auth.py +116 -0
- cassetta-0.30.0/src/cassetta/mcp_server.py +1451 -0
- cassetta-0.30.0/src/cassetta/middleware.py +99 -0
- cassetta-0.30.0/src/cassetta/mime.py +43 -0
- cassetta-0.30.0/src/cassetta/models.py +226 -0
- cassetta-0.30.0/src/cassetta/path_validation.py +36 -0
- cassetta-0.30.0/src/cassetta/protocols/__init__.py +0 -0
- cassetta-0.30.0/src/cassetta/protocols/access.py +54 -0
- cassetta-0.30.0/src/cassetta/protocols/alias.py +45 -0
- cassetta-0.30.0/src/cassetta/protocols/claim_storage.py +76 -0
- cassetta-0.30.0/src/cassetta/protocols/config.py +103 -0
- cassetta-0.30.0/src/cassetta/protocols/identity.py +34 -0
- cassetta-0.30.0/src/cassetta/protocols/keystore.py +41 -0
- cassetta-0.30.0/src/cassetta/protocols/limits.py +119 -0
- cassetta-0.30.0/src/cassetta/protocols/metrics.py +52 -0
- cassetta-0.30.0/src/cassetta/protocols/reference_transport.py +47 -0
- cassetta-0.30.0/src/cassetta/protocols/storage.py +75 -0
- cassetta-0.30.0/src/cassetta/py.typed +0 -0
- cassetta-0.30.0/src/cassetta/rate_limit/__init__.py +23 -0
- cassetta-0.30.0/src/cassetta/rate_limit/limiter.py +161 -0
- cassetta-0.30.0/src/cassetta/routes/__init__.py +0 -0
- cassetta-0.30.0/src/cassetta/routes/agents.py +378 -0
- cassetta-0.30.0/src/cassetta/routes/capabilities.py +42 -0
- cassetta-0.30.0/src/cassetta/routes/download.py +432 -0
- cassetta-0.30.0/src/cassetta/routes/files.py +650 -0
- cassetta-0.30.0/src/cassetta/routes/inbox.py +577 -0
- cassetta-0.30.0/src/cassetta/routes/keys.py +246 -0
- cassetta-0.30.0/src/cassetta/routes/upload.py +428 -0
- cassetta-0.30.0/src/cassetta/routes/uploads.py +107 -0
- cassetta-0.30.0/src/cassetta/send_init.py +329 -0
- cassetta-0.30.0/src/cassetta/streaming.py +68 -0
- cassetta-0.30.0/src/cassetta/structured_log.py +290 -0
- cassetta-0.30.0/tests/auth/__init__.py +0 -0
- cassetta-0.30.0/tests/auth/conftest.py +114 -0
- cassetta-0.30.0/tests/auth/test_dependencies.py +154 -0
- cassetta-0.30.0/tests/auth/test_jwt_aud_placeholder.py +39 -0
- cassetta-0.30.0/tests/auth/test_jwt_hot_reload.py +494 -0
- cassetta-0.30.0/tests/auth/test_observability.py +235 -0
- cassetta-0.30.0/tests/conftest.py +358 -0
- cassetta-0.30.0/tests/integration/bundle_layout/__init__.py +0 -0
- cassetta-0.30.0/tests/integration/bundle_layout/test_atomic_visibility.py +103 -0
- cassetta-0.30.0/tests/integration/bundle_layout/test_mime_inference_roundtrip.py +167 -0
- cassetta-0.30.0/tests/integration/bundle_layout/test_multicast_fresh_bundle_id.py +45 -0
- cassetta-0.30.0/tests/integration/bundle_layout/test_rest_roundtrip.py +256 -0
- cassetta-0.30.0/tests/integration/conftest.py +349 -0
- cassetta-0.30.0/tests/integration/test_capabilities_mcp.py +328 -0
- cassetta-0.30.0/tests/integration/test_capabilities_mcp_rest_parity.py +75 -0
- cassetta-0.30.0/tests/integration/test_capabilities_no_side_effects.py +102 -0
- cassetta-0.30.0/tests/integration/test_capabilities_rest.py +90 -0
- cassetta-0.30.0/tests/integration/test_claim_expiry.py +123 -0
- cassetta-0.30.0/tests/integration/test_cli_capabilities.py +139 -0
- cassetta-0.30.0/tests/integration/test_cli_download.py +224 -0
- cassetta-0.30.0/tests/integration/test_cli_send_contract.py +171 -0
- cassetta-0.30.0/tests/integration/test_cli_upload_contract.py +170 -0
- cassetta-0.30.0/tests/integration/test_client_precheck_pattern.py +138 -0
- cassetta-0.30.0/tests/integration/test_concurrent_pick.py +90 -0
- cassetta-0.30.0/tests/integration/test_disk_pressure_1gb.py +301 -0
- cassetta-0.30.0/tests/integration/test_download_endpoint_matrix.py +491 -0
- cassetta-0.30.0/tests/integration/test_download_identity_mismatch.py +132 -0
- cassetta-0.30.0/tests/integration/test_get_inbox_rest_reference.py +74 -0
- cassetta-0.30.0/tests/integration/test_get_store_reference.py +88 -0
- cassetta-0.30.0/tests/integration/test_inbox_listing_hides_claimed.py +125 -0
- cassetta-0.30.0/tests/integration/test_jwt_rotation.py +64 -0
- cassetta-0.30.0/tests/integration/test_legacy_send_removed.py +79 -0
- cassetta-0.30.0/tests/integration/test_manifest_pre_bytes_rejection.py +278 -0
- cassetta-0.30.0/tests/integration/test_mcp_description_hygiene.py +63 -0
- cassetta-0.30.0/tests/integration/test_mcp_send_init_contract.py +154 -0
- cassetta-0.30.0/tests/integration/test_mcp_send_inline_contract.py +313 -0
- cassetta-0.30.0/tests/integration/test_mcp_server_version.py +39 -0
- cassetta-0.30.0/tests/integration/test_observability_contract.py +509 -0
- cassetta-0.30.0/tests/integration/test_pick_inline.py +143 -0
- cassetta-0.30.0/tests/integration/test_pick_reference.py +128 -0
- cassetta-0.30.0/tests/integration/test_read_surface_migration.py +140 -0
- cassetta-0.30.0/tests/integration/test_reaper_runs_in_lifespan.py +34 -0
- cassetta-0.30.0/tests/integration/test_reaper_sweeps_orphans.py +46 -0
- cassetta-0.30.0/tests/integration/test_rest_upload_contract.py +132 -0
- cassetta-0.30.0/tests/integration/test_rest_upload_init.py +243 -0
- cassetta-0.30.0/tests/integration/test_scenario_01_full_lifecycle.py +83 -0
- cassetta-0.30.0/tests/integration/test_scenario_02_two_agents.py +54 -0
- cassetta-0.30.0/tests/integration/test_scenario_03_discovery_broadcast.py +61 -0
- cassetta-0.30.0/tests/integration/test_scenario_04_multi_file_bundle.py +95 -0
- cassetta-0.30.0/tests/integration/test_scenario_05_send_validation.py +37 -0
- cassetta-0.30.0/tests/integration/test_scenario_06_mcp_tools.py +90 -0
- cassetta-0.30.0/tests/integration/test_scenario_07_key_rotation.py +63 -0
- cassetta-0.30.0/tests/integration/test_scenario_08_ttl_expiry.py +50 -0
- cassetta-0.30.0/tests/integration/test_send_init_batch.py +148 -0
- cassetta-0.30.0/tests/integration/test_send_init_inline.py +121 -0
- cassetta-0.30.0/tests/integration/test_send_roundtrip.py +141 -0
- cassetta-0.30.0/tests/integration/test_server_restart_claim_survival.py +138 -0
- cassetta-0.30.0/tests/integration/test_single_use_passive.py +197 -0
- cassetta-0.30.0/tests/integration/test_stateless_across_restart.py +141 -0
- cassetta-0.30.0/tests/integration/test_upload_rollback.py +140 -0
- cassetta-0.30.0/tests/observability/_obs_helpers.py +103 -0
- cassetta-0.30.0/tests/observability/conftest.py +179 -0
- cassetta-0.30.0/tests/observability/test_active_keys_rotate.py +38 -0
- cassetta-0.30.0/tests/observability/test_audit_no_raw_metric_calls.py +56 -0
- cassetta-0.30.0/tests/observability/test_auth_logger_isolation.py +173 -0
- cassetta-0.30.0/tests/observability/test_capabilities_metric.py +63 -0
- cassetta-0.30.0/tests/observability/test_download_metrics.py +258 -0
- cassetta-0.30.0/tests/observability/test_inbox_metrics.py +203 -0
- cassetta-0.30.0/tests/observability/test_mcp_enforce_metric.py +142 -0
- cassetta-0.30.0/tests/observability/test_policy_kind_tag.py +155 -0
- cassetta-0.30.0/tests/observability/test_safe_emit.py +249 -0
- cassetta-0.30.0/tests/observability/test_upload_metrics.py +199 -0
- cassetta-0.30.0/tests/rate_limit/__init__.py +0 -0
- cassetta-0.30.0/tests/rate_limit/conftest.py +66 -0
- cassetta-0.30.0/tests/rate_limit/test_broadcast_mcp.py +203 -0
- cassetta-0.30.0/tests/rate_limit/test_broadcast_rest.py +201 -0
- cassetta-0.30.0/tests/rate_limit/test_limiter_instance.py +104 -0
- cassetta-0.30.0/tests/rate_limit/test_shared_budget.py +183 -0
- cassetta-0.30.0/tests/rate_limit/test_trusted_proxies.py +119 -0
- cassetta-0.30.0/tests/test_access.py +65 -0
- cassetta-0.30.0/tests/test_access_denial.py +146 -0
- cassetta-0.30.0/tests/test_access_wiring.py +463 -0
- cassetta-0.30.0/tests/test_agents.py +341 -0
- cassetta-0.30.0/tests/test_alias_protocol.py +48 -0
- cassetta-0.30.0/tests/test_app_has_no_concrete_default_imports.py +74 -0
- cassetta-0.30.0/tests/test_auth.py +114 -0
- cassetta-0.30.0/tests/test_broadcast.py +661 -0
- cassetta-0.30.0/tests/test_broadcast_url.py +113 -0
- cassetta-0.30.0/tests/test_bundle_listing.py +147 -0
- cassetta-0.30.0/tests/test_bundle_rest.py +232 -0
- cassetta-0.30.0/tests/test_bundle_writer.py +176 -0
- cassetta-0.30.0/tests/test_capabilities_features_policy.py +49 -0
- cassetta-0.30.0/tests/test_client_install.py +243 -0
- cassetta-0.30.0/tests/test_concurrent_access.py +65 -0
- cassetta-0.30.0/tests/test_config.py +267 -0
- cassetta-0.30.0/tests/test_config_dead_envvars.py +68 -0
- cassetta-0.30.0/tests/test_config_protocol.py +255 -0
- cassetta-0.30.0/tests/test_cross_protocol.py +236 -0
- cassetta-0.30.0/tests/test_declared_dependencies.py +226 -0
- cassetta-0.30.0/tests/test_default_access.py +47 -0
- cassetta-0.30.0/tests/test_default_alias.py +94 -0
- cassetta-0.30.0/tests/test_default_config.py +13 -0
- cassetta-0.30.0/tests/test_dev_mode_observability.py +203 -0
- cassetta-0.30.0/tests/test_docs_examples.py +996 -0
- cassetta-0.30.0/tests/test_docs_version_pins.py +250 -0
- cassetta-0.30.0/tests/test_download_auth_failure.py +200 -0
- cassetta-0.30.0/tests/test_files.py +165 -0
- cassetta-0.30.0/tests/test_health.py +9 -0
- cassetta-0.30.0/tests/test_identity.py +136 -0
- cassetta-0.30.0/tests/test_inbox.py +416 -0
- cassetta-0.30.0/tests/test_inbox_mcp.py +236 -0
- cassetta-0.30.0/tests/test_key_creation.py +129 -0
- cassetta-0.30.0/tests/test_keys.py +212 -0
- cassetta-0.30.0/tests/test_keys_protocol_no_typeerror.py +50 -0
- cassetta-0.30.0/tests/test_layer2_uses_protocol_annotations.py +129 -0
- cassetta-0.30.0/tests/test_lease.py +81 -0
- cassetta-0.30.0/tests/test_limits_wire.py +357 -0
- cassetta-0.30.0/tests/test_listing_reads_only_meta.py +83 -0
- cassetta-0.30.0/tests/test_logging.py +456 -0
- cassetta-0.30.0/tests/test_mcp_auth.py +245 -0
- cassetta-0.30.0/tests/test_mcp_bundles.py +365 -0
- cassetta-0.30.0/tests/test_mcp_identity.py +53 -0
- cassetta-0.30.0/tests/test_mcp_tools.py +610 -0
- cassetta-0.30.0/tests/test_metrics.py +351 -0
- cassetta-0.30.0/tests/test_mime_inference.py +69 -0
- cassetta-0.30.0/tests/test_models_inventory.py +88 -0
- cassetta-0.30.0/tests/test_namespace_isolation.py +76 -0
- cassetta-0.30.0/tests/test_no_cloud_imports_in_core.py +30 -0
- cassetta-0.30.0/tests/test_openapi_leakage.py +121 -0
- cassetta-0.30.0/tests/test_openapi_version.py +40 -0
- cassetta-0.30.0/tests/test_path_validation.py +63 -0
- cassetta-0.30.0/tests/test_peek_mcp.py +202 -0
- cassetta-0.30.0/tests/test_peek_rest.py +177 -0
- cassetta-0.30.0/tests/test_protocols_declare_kind.py +98 -0
- cassetta-0.30.0/tests/test_public_surface.py +1111 -0
- cassetta-0.30.0/tests/test_rate_limit_route_attribution.py +154 -0
- cassetta-0.30.0/tests/test_release_metadata.py +518 -0
- cassetta-0.30.0/tests/test_request_id.py +107 -0
- cassetta-0.30.0/tests/test_send_init_parity.py +108 -0
- cassetta-0.30.0/tests/test_send_validation.py +142 -0
- cassetta-0.30.0/tests/test_send_with_policy.py +241 -0
- cassetta-0.30.0/tests/test_sender_label.py +59 -0
- cassetta-0.30.0/tests/test_signing_key_fixtures.py +88 -0
- cassetta-0.30.0/tests/test_smoke_script.py +157 -0
- cassetta-0.30.0/tests/test_startup_log_claim_backend.py +57 -0
- cassetta-0.30.0/tests/test_storage.py +96 -0
- cassetta-0.30.0/tests/test_store_path_collision.py +81 -0
- cassetta-0.30.0/tests/test_ttl.py +122 -0
- cassetta-0.30.0/tests/test_ttl_cleanup.py +160 -0
- cassetta-0.30.0/tests/test_unified_identity.py +140 -0
- cassetta-0.30.0/tests/test_unlocked_server_resolution.py +92 -0
- cassetta-0.30.0/tests/test_upload_per_file_cap.py +192 -0
- cassetta-0.30.0/tests/test_workflows.py +525 -0
- cassetta-0.30.0/tests/unit/test_app_wiring_limits.py +142 -0
- cassetta-0.30.0/tests/unit/test_boot_fails_without_keys.py +68 -0
- cassetta-0.30.0/tests/unit/test_capabilities_assembly.py +238 -0
- cassetta-0.30.0/tests/unit/test_claim_sidecar.py +308 -0
- cassetta-0.30.0/tests/unit/test_claim_storage_protocol_conformance.py +13 -0
- cassetta-0.30.0/tests/unit/test_config_loading.py +190 -0
- cassetta-0.30.0/tests/unit/test_gc_claims.py +210 -0
- cassetta-0.30.0/tests/unit/test_gc_reaper.py +133 -0
- cassetta-0.30.0/tests/unit/test_jwt_revocation_hook.py +71 -0
- cassetta-0.30.0/tests/unit/test_jwt_tokens.py +153 -0
- cassetta-0.30.0/tests/unit/test_key_source_precedence.py +69 -0
- cassetta-0.30.0/tests/unit/test_limits_config.py +179 -0
- cassetta-0.30.0/tests/unit/test_limits_policy_evaluate.py +173 -0
- cassetta-0.30.0/tests/unit/test_limits_policy_introspection.py +334 -0
- cassetta-0.30.0/tests/unit/test_limits_policy_skeleton.py +23 -0
- cassetta-0.30.0/tests/unit/test_limits_protocol_shape.py +71 -0
- cassetta-0.30.0/tests/unit/test_limits_shared_helper.py +276 -0
- cassetta-0.30.0/tests/unit/test_manifest_validation.py +76 -0
- cassetta-0.30.0/tests/unit/test_no_jti_store.py +42 -0
- cassetta-0.30.0/tests/unit/test_peek_never_reads_bytes.py +103 -0
- cassetta-0.30.0/tests/unit/test_peek_unit.py +264 -0
- cassetta-0.30.0/tests/unit/test_reference_transport.py +77 -0
- cassetta-0.30.0/tests/unit/test_streaming_sync_reader.py +107 -0
- cassetta-0.30.0/uv.lock +1325 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Build context = the repository root. Only `pyproject.toml`, `uv.lock`, `LICENSE`, `src/` and
|
|
2
|
+
# `scripts/docker-entrypoint.sh` are actually COPYed by the Dockerfile, but keeping the context
|
|
3
|
+
# small speeds every build and keeps a stray `.env` from ever reaching the daemon.
|
|
4
|
+
#
|
|
5
|
+
# `scripts/` is therefore not excluded below, and must not become excluded: the entrypoint is the
|
|
6
|
+
# container's first process.
|
|
7
|
+
|
|
8
|
+
# Version control
|
|
9
|
+
.git/
|
|
10
|
+
.gitignore
|
|
11
|
+
.github/
|
|
12
|
+
|
|
13
|
+
# Python build + cache artifacts
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*.egg-info/
|
|
17
|
+
dist/
|
|
18
|
+
build/
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
|
|
22
|
+
# Test, lint, and type-check state
|
|
23
|
+
tests/
|
|
24
|
+
.pytest_cache/
|
|
25
|
+
.mypy_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
htmlcov/
|
|
28
|
+
.coverage
|
|
29
|
+
|
|
30
|
+
# Docs — not needed to run the service
|
|
31
|
+
docs/
|
|
32
|
+
|
|
33
|
+
# Runtime state: never bake a developer's bus contents into an image
|
|
34
|
+
data/
|
|
35
|
+
data.keys/
|
|
36
|
+
|
|
37
|
+
# Secrets
|
|
38
|
+
.env
|
|
39
|
+
.env.local
|
|
40
|
+
|
|
41
|
+
# The docker files themselves
|
|
42
|
+
Dockerfile
|
|
43
|
+
docker-compose.yml
|
|
44
|
+
.dockerignore
|
|
45
|
+
|
|
46
|
+
# Editor and OS noise
|
|
47
|
+
.idea/
|
|
48
|
+
.vscode/
|
|
49
|
+
*.swp
|
|
50
|
+
.DS_Store
|
|
51
|
+
Thumbs.db
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Cassetta configuration — copy to `.env` and edit.
|
|
2
|
+
#
|
|
3
|
+
# cp .env.example .env
|
|
4
|
+
#
|
|
5
|
+
# Every value below is read from the environment at startup. Three are required; the rest show
|
|
6
|
+
# their built-in default, so a line you leave commented out behaves exactly as shown.
|
|
7
|
+
# Full reference: docs/CONFIG.md
|
|
8
|
+
#
|
|
9
|
+
# To change a value later, edit `.env` and run `docker compose up -d` again — no image rebuild.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Required
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
# Token for the one-time POST /setup call that mints your first API key.
|
|
17
|
+
# Set this to a long random string before exposing the server to anything.
|
|
18
|
+
# Setting it to "" enables dev mode — NO AUTHENTICATION AT ALL. Local experiments only.
|
|
19
|
+
CASSETTA_SETUP_TOKEN=change-me-to-a-long-random-string
|
|
20
|
+
|
|
21
|
+
# Absolute base URL that agents can reach. Used to compose the download / upload URLs
|
|
22
|
+
# returned in batch responses, so it must be the externally reachable address — not the
|
|
23
|
+
# internal bind address, when those differ. Scheme is mandatory (http:// or https://).
|
|
24
|
+
CASSETTA_PUBLIC_BASE_URL=http://localhost:16001
|
|
25
|
+
|
|
26
|
+
# ###########################################################################
|
|
27
|
+
# # DEV ONLY — the key below is a fixed placeholder shipped with this file. #
|
|
28
|
+
# # It decodes to the literal "dev-only-quickstart-key-DO-NOT-USE-IN-PRODUCTION",
|
|
29
|
+
# # so anyone who cloned this repository can mint a valid token against it. #
|
|
30
|
+
# # REPLACE IT before any deployment beyond localhost: #
|
|
31
|
+
# # openssl rand -base64 32 #
|
|
32
|
+
# ###########################################################################
|
|
33
|
+
# Base64-encoded HS256 signing key; must decode to at least 32 bytes.
|
|
34
|
+
CASSETTA_JWT_KEY=ZGV2LW9ubHktcXVpY2tzdGFydC1rZXktRE8tTk9ULVVTRS1JTi1QUk9EVUNUSU9O
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Signing keys (optional)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Path to a file whose contents are the base64-encoded primary key. Wins over
|
|
42
|
+
# CASSETTA_JWT_KEY when both are set — the managed-secret setup.
|
|
43
|
+
#CASSETTA_JWT_KEY_FILE=
|
|
44
|
+
|
|
45
|
+
# Verify-only secondary key, used during rotation. Never signs. See docs/CONFIG.md
|
|
46
|
+
# "Key rotation procedure".
|
|
47
|
+
#CASSETTA_JWT_KEY_SECONDARY=
|
|
48
|
+
#CASSETTA_JWT_KEY_SECONDARY_FILE=
|
|
49
|
+
|
|
50
|
+
# Seconds a just-demoted key stays valid after a SIGHUP rotation, so in-flight tokens
|
|
51
|
+
# keep verifying. Must be >= max(CASSETTA_DOWNLOAD_CLAIM_TTL, CASSETTA_UPLOAD_TOKEN_TTL).
|
|
52
|
+
#CASSETTA_JWT_KEY_OVERLAP_TTL=600
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Storage
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
# Storage path. /data is the container mount; outside a container the default is ./data.
|
|
60
|
+
CASSETTA_STORAGE_PATH=/data
|
|
61
|
+
|
|
62
|
+
# API key store. Defaults to a sibling of the storage directory —
|
|
63
|
+
# "<storage>.keys/.cassetta-keys.json" — so key files live outside the storage tree and
|
|
64
|
+
# never appear in a /files/ listing.
|
|
65
|
+
#CASSETTA_KEYS_FILE=
|
|
66
|
+
|
|
67
|
+
# Default file TTL in seconds. 0 disables expiration.
|
|
68
|
+
#CASSETTA_DEFAULT_TTL=0
|
|
69
|
+
|
|
70
|
+
# Character class allowed in stored paths.
|
|
71
|
+
#CASSETTA_ALLOWED_PATH_CHARS=a-zA-Z0-9\-_./
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Limits policy
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# For the four caps, an empty value means "no cap" — explicitly, so configuration
|
|
78
|
+
# management that cannot distinguish an absent variable from an empty one can still clear one.
|
|
79
|
+
|
|
80
|
+
# Maximum bytes for any single file in a bundle. Empty = uncapped.
|
|
81
|
+
#CASSETTA_PER_FILE_MAX=
|
|
82
|
+
|
|
83
|
+
# Maximum total bytes across a bundle. Empty = uncapped.
|
|
84
|
+
#CASSETTA_PER_BUNDLE_TOTAL_MAX=
|
|
85
|
+
|
|
86
|
+
# Maximum number of files per bundle. Empty = unlimited.
|
|
87
|
+
#CASSETTA_PER_BUNDLE_FILE_COUNT_MAX=25
|
|
88
|
+
|
|
89
|
+
# Payloads at or below this size are accepted inline; larger ones require the batch
|
|
90
|
+
# upload flow. 102400 = 100 KiB. Empty = always inline.
|
|
91
|
+
#CASSETTA_MAX_INLINE_SIZE=102400
|
|
92
|
+
|
|
93
|
+
# TTLs, in seconds. All must be > 0.
|
|
94
|
+
#CASSETTA_UPLOAD_TOKEN_TTL=300
|
|
95
|
+
#CASSETTA_DOWNLOAD_CLAIM_TTL=300
|
|
96
|
+
|
|
97
|
+
# Passive garbage collection of orphaned bundles.
|
|
98
|
+
#CASSETTA_PASSIVE_GC_MIN_AGE=3600
|
|
99
|
+
#CASSETTA_PASSIVE_GC_INTERVAL=600
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Rate limiting and fan-out
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
# Shared budget across REST /broadcast and the MCP cassetta_broadcast tool.
|
|
107
|
+
# Format "<int>/<unit>", unit one of sec, second, min, minute, hour, hourly.
|
|
108
|
+
#CASSETTA_RATE_LIMIT_BROADCAST=10/minute
|
|
109
|
+
|
|
110
|
+
# Fan-out cap. A broadcast above this many recipients is rejected before any write.
|
|
111
|
+
#CASSETTA_BROADCAST_MAX_TARGETS=1000
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# MCP and logging
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
# Comma-separated Host header allowlist for the /mcp endpoint (DNS-rebinding protection).
|
|
119
|
+
# Empty means localhost only — set your external hostname(s) to reach MCP from another machine.
|
|
120
|
+
#CASSETTA_MCP_ALLOWED_HOSTS=
|
|
121
|
+
|
|
122
|
+
# "text" or "json".
|
|
123
|
+
#CASSETTA_LOG_FORMAT=text
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Compose only
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Read by docker-compose.yml, never by the server. It selects the host port to publish;
|
|
130
|
+
# the port inside the container is fixed at 16001.
|
|
131
|
+
#CASSETTA_PORT=16001
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Continuous integration for every pull request, and for the default branch itself.
|
|
2
|
+
#
|
|
3
|
+
# This workflow carries exactly the gates a contributor already runs locally, and nothing more.
|
|
4
|
+
# It is meant to be cheap enough to require on every merge, so anything that builds an image or
|
|
5
|
+
# starts a stack belongs in the weekly smoke workflow instead — never here.
|
|
6
|
+
|
|
7
|
+
name: CI
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
pull_request:
|
|
11
|
+
branches: [main]
|
|
12
|
+
# So the default branch carries its own status rather than inheriting the last pull request's.
|
|
13
|
+
push:
|
|
14
|
+
branches: [main]
|
|
15
|
+
|
|
16
|
+
# The run reads this repository and writes nothing back. It needs no secret, no registry and no
|
|
17
|
+
# token beyond the read-only one below.
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
|
|
21
|
+
# A force-pushed branch should not leave superseded runs burning minutes on a status nobody
|
|
22
|
+
# will read.
|
|
23
|
+
concurrency:
|
|
24
|
+
group: ci-${{ github.ref }}
|
|
25
|
+
cancel-in-progress: true
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
lint:
|
|
29
|
+
name: Format, lint and types
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v6
|
|
33
|
+
|
|
34
|
+
- uses: astral-sh/setup-uv@v7
|
|
35
|
+
with:
|
|
36
|
+
python-version: "3.13"
|
|
37
|
+
|
|
38
|
+
# `--extra dev` is load-bearing. The gate tools live in [project.optional-dependencies],
|
|
39
|
+
# which uv treats as an extra rather than as a default dependency group, so a bare
|
|
40
|
+
# `uv sync` uninstalls pytest, ruff and mypy and every step below then fails.
|
|
41
|
+
- run: uv sync --extra dev
|
|
42
|
+
|
|
43
|
+
# One step per gate, so a failure is named in the run's step list rather than buried.
|
|
44
|
+
- run: uv run ruff format --check
|
|
45
|
+
- run: uv run ruff check
|
|
46
|
+
- run: uv run mypy
|
|
47
|
+
|
|
48
|
+
test:
|
|
49
|
+
name: Test suite
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
# Default fetch depth is correct: the one test that shells out to git reads `git ls-files`,
|
|
53
|
+
# which is the index and needs no history.
|
|
54
|
+
- uses: actions/checkout@v6
|
|
55
|
+
|
|
56
|
+
- uses: astral-sh/setup-uv@v7
|
|
57
|
+
with:
|
|
58
|
+
python-version: "3.13"
|
|
59
|
+
|
|
60
|
+
- run: uv sync --extra dev
|
|
61
|
+
|
|
62
|
+
- run: uv run pytest
|
|
63
|
+
|
|
64
|
+
# Every test must also pass under a randomised ordering — interdependence between tests is
|
|
65
|
+
# a defect here, not a quirk.
|
|
66
|
+
- run: uv run pytest -p randomly
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Build the distribution for a release, and prove it is the release it claims to be.
|
|
2
|
+
#
|
|
3
|
+
# Reusable on purpose, and reusable *only*. What defines a release here is the tag, and the tag is
|
|
4
|
+
# placed by tag.yml after a merge — so this workflow is called from there, in the same run, with the
|
|
5
|
+
# version that was just tagged. There is nothing this could usefully mean on its own, which is why it
|
|
6
|
+
# carries no other trigger.
|
|
7
|
+
#
|
|
8
|
+
# It builds and checks; it does not publish, and it cannot. A called workflow can narrow the token
|
|
9
|
+
# its caller grants but never widen it, so a job here could not obtain the identity token publishing
|
|
10
|
+
# needs, and the index does not support a reusable workflow as a trusted publisher in any case. The
|
|
11
|
+
# publish job is in tag.yml, after the call to this one, and it hands over the artifact uploaded
|
|
12
|
+
# below. tests/test_workflows.py holds publishing to that one place.
|
|
13
|
+
|
|
14
|
+
name: Release
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
workflow_call:
|
|
18
|
+
inputs:
|
|
19
|
+
version:
|
|
20
|
+
description: The version that was just tagged, without the leading `v`.
|
|
21
|
+
required: true
|
|
22
|
+
type: string
|
|
23
|
+
|
|
24
|
+
# It reads the tree and writes nothing back — the tag already exists by the time this runs.
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
build:
|
|
30
|
+
name: Build the distribution and check it against the tag
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v6
|
|
34
|
+
|
|
35
|
+
- uses: astral-sh/setup-uv@v7
|
|
36
|
+
with:
|
|
37
|
+
python-version: "3.13"
|
|
38
|
+
|
|
39
|
+
# No `--extra dev` here: a build needs the build backend, not the gate tools. CI has already
|
|
40
|
+
# run those against this very commit.
|
|
41
|
+
- run: uv build
|
|
42
|
+
|
|
43
|
+
# The step this whole composition exists for. The version in the built artifact is derived
|
|
44
|
+
# from src/cassetta/__init__.py by the build backend; the version in the tag was read from the
|
|
45
|
+
# same file by tag.yml. Asserting they match is what turns "they are both read from one place"
|
|
46
|
+
# from an argument into an observation.
|
|
47
|
+
#
|
|
48
|
+
# The input reaches the script through the environment rather than being interpolated into the
|
|
49
|
+
# script body. The value comes from this repository's own tree, so nothing hostile can reach
|
|
50
|
+
# it — but a `run:` block with an expression spliced into it is a shape a reviewer has to stop
|
|
51
|
+
# and think about, and this one gets exactly one review.
|
|
52
|
+
- name: The built artifact must carry the version that was tagged
|
|
53
|
+
env:
|
|
54
|
+
EXPECTED: ${{ inputs.version }}
|
|
55
|
+
run: |
|
|
56
|
+
set -euo pipefail
|
|
57
|
+
|
|
58
|
+
wheel="dist/cassetta-${EXPECTED}-py3-none-any.whl"
|
|
59
|
+
sdist="dist/cassetta-${EXPECTED}.tar.gz"
|
|
60
|
+
|
|
61
|
+
missing=false
|
|
62
|
+
for artifact in "$wheel" "$sdist"; do
|
|
63
|
+
if [ ! -f "$artifact" ]; then
|
|
64
|
+
printf 'expected %s, which was not built\n' "$artifact" >&2
|
|
65
|
+
missing=true
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
if [ "$missing" = true ]; then
|
|
70
|
+
printf '\nthe tag says %s; the build produced:\n' "$EXPECTED" >&2
|
|
71
|
+
ls -1 dist/ >&2
|
|
72
|
+
printf '\nThe tag and the distribution disagree about what this release is.\n' >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
printf 'dist/ carries %s, which is what was tagged\n' "$EXPECTED"
|
|
77
|
+
|
|
78
|
+
# What tag.yml's publish job hands to the index, once a reviewer approves the run — these bytes,
|
|
79
|
+
# which the step above checked against the tag, and never a second build. It downloads them by
|
|
80
|
+
# this name, so the two are renamed together; tests/test_workflows.py compares them.
|
|
81
|
+
- uses: actions/upload-artifact@v7
|
|
82
|
+
with:
|
|
83
|
+
name: cassetta-${{ inputs.version }}
|
|
84
|
+
path: dist/
|
|
85
|
+
if-no-files-found: error
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Weekly proof that the container this repository ships still boots and serves traffic.
|
|
2
|
+
#
|
|
3
|
+
# Deliberately off the merge path. Building an image and starting a stack is far too expensive to
|
|
4
|
+
# run on every pull request; the change flow here is low and history is linear and squash-merged,
|
|
5
|
+
# so a weekly signal is enough to identify which recent merge broke it.
|
|
6
|
+
#
|
|
7
|
+
# The shell lives in scripts/smoke.sh rather than in this file, so a contributor can run exactly
|
|
8
|
+
# what the schedule runs against their own clone.
|
|
9
|
+
#
|
|
10
|
+
# It runs twice, against bind mounts owned two different ways. The second run is the only thing in
|
|
11
|
+
# this repository that exercises the case a hosted runner's own user id conceals; see the comment on
|
|
12
|
+
# that job for why one run was never enough.
|
|
13
|
+
|
|
14
|
+
name: Smoke
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
schedule:
|
|
18
|
+
# Mondays, off the hour on purpose: scheduled runs at minute zero are heavily contended on
|
|
19
|
+
# the platform and get delayed, sometimes substantially.
|
|
20
|
+
- cron: "17 6 * * 1"
|
|
21
|
+
# So the first proof that any of this works does not have to wait up to a week.
|
|
22
|
+
workflow_dispatch:
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: smoke-${{ github.ref }}
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
changes:
|
|
33
|
+
name: Anything landed since the last green run?
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
# For the Actions API query below.
|
|
38
|
+
actions: read
|
|
39
|
+
outputs:
|
|
40
|
+
should_run: ${{ steps.decide.outputs.should_run }}
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v6
|
|
43
|
+
with:
|
|
44
|
+
# The commit count below needs history to count over.
|
|
45
|
+
fetch-depth: 0
|
|
46
|
+
|
|
47
|
+
- id: decide
|
|
48
|
+
env:
|
|
49
|
+
GH_TOKEN: ${{ github.token }}
|
|
50
|
+
run: |
|
|
51
|
+
set -euo pipefail
|
|
52
|
+
|
|
53
|
+
decide() {
|
|
54
|
+
printf 'should_run=%s — %s\n' "$1" "$2"
|
|
55
|
+
echo "should_run=$1" >>"$GITHUB_OUTPUT"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Key on the last *successful* run rather than on a calendar window, so a run that
|
|
59
|
+
# fails and is fixed ten days later still measures from the right place. The run
|
|
60
|
+
# executing this query cannot match it: it is in_progress, not success.
|
|
61
|
+
query="repos/${GITHUB_REPOSITORY}/actions/workflows/smoke.yml/runs?status=success&branch=main&per_page=1"
|
|
62
|
+
|
|
63
|
+
if baseline="$(gh api "$query" --jq '.workflow_runs[0].head_sha // ""')"; then
|
|
64
|
+
api_reachable=true
|
|
65
|
+
else
|
|
66
|
+
api_reachable=false
|
|
67
|
+
baseline=""
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Every unknown below resolves to "run". A wrong "run" costs one unnecessary run; a
|
|
71
|
+
# wrong "skip" is silent and can keep skipping forever, which is the failure nobody
|
|
72
|
+
# notices.
|
|
73
|
+
if [ "$api_reachable" = false ]; then
|
|
74
|
+
decide true "the Actions API could not be queried; running rather than skipping blind"
|
|
75
|
+
elif [ -z "$baseline" ]; then
|
|
76
|
+
decide true "no successful run of this workflow exists yet"
|
|
77
|
+
elif ! git cat-file -e "${baseline}^{commit}" 2>/dev/null; then
|
|
78
|
+
decide true "baseline ${baseline} is not in the fetched history (rewritten, or older than the checkout)"
|
|
79
|
+
else
|
|
80
|
+
landed="$(git rev-list --count "${baseline}..HEAD")"
|
|
81
|
+
if [ "$landed" -gt 0 ]; then
|
|
82
|
+
decide true "${landed} commit(s) landed since ${baseline}"
|
|
83
|
+
else
|
|
84
|
+
decide false "nothing has landed since ${baseline}"
|
|
85
|
+
fi
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
smoke:
|
|
89
|
+
name: Container smoke
|
|
90
|
+
needs: changes
|
|
91
|
+
if: needs.changes.outputs.should_run == 'true'
|
|
92
|
+
runs-on: ubuntu-latest
|
|
93
|
+
steps:
|
|
94
|
+
- uses: actions/checkout@v6
|
|
95
|
+
|
|
96
|
+
# One step, on purpose. Dumping logs on failure and tearing the stack down live in the
|
|
97
|
+
# script's own exit trap, so a contributor running it locally gets exactly this behaviour.
|
|
98
|
+
- run: ./scripts/smoke.sh
|
|
99
|
+
|
|
100
|
+
smoke-foreign-uid:
|
|
101
|
+
name: Container smoke, bind mounts owned by another uid
|
|
102
|
+
needs: changes
|
|
103
|
+
if: needs.changes.outputs.should_run == 'true'
|
|
104
|
+
runs-on: ubuntu-latest
|
|
105
|
+
steps:
|
|
106
|
+
- uses: actions/checkout@v6
|
|
107
|
+
|
|
108
|
+
# The case the job above is structurally unable to see, and the reason this one is permanent.
|
|
109
|
+
#
|
|
110
|
+
# A hosted runner's own user carries uid 1001 — the same id as the account inside the image —
|
|
111
|
+
# so a bind mount the runner creates is already owned by exactly the right user. The job above
|
|
112
|
+
# therefore passes for a reason that has nothing to do with the code, and would keep passing
|
|
113
|
+
# if the container lost the ability to write into a directory it did not create. Handing the
|
|
114
|
+
# two directories to a different uid first is what reproduces the case an ordinary Linux
|
|
115
|
+
# contributor meets: the quickstart's `mkdir -p` leaves them owned by whoever ran it.
|
|
116
|
+
#
|
|
117
|
+
# 1000 is the first ordinary user id on a Debian or Ubuntu desktop, so it stands in for
|
|
118
|
+
# precisely that contributor. Creating the directories here rather than letting the script do
|
|
119
|
+
# it is load-bearing — the script creates them itself when they are absent, and one it creates
|
|
120
|
+
# belongs to the runner again.
|
|
121
|
+
#
|
|
122
|
+
# Both ownership cases are kept because they fail for different reasons. Deleting either loses
|
|
123
|
+
# a signal, and deleting this one returns the weekly run to passing by coincidence.
|
|
124
|
+
- name: Hand the bind-mount sources to a uid that is not the runner's
|
|
125
|
+
run: |
|
|
126
|
+
set -euo pipefail
|
|
127
|
+
mkdir -p data data.keys
|
|
128
|
+
sudo chown 1000:1000 data data.keys
|
|
129
|
+
printf 'runner uid: %s (the container runs as uid 1001)\n' "$(id -u)"
|
|
130
|
+
ls -ld data data.keys
|
|
131
|
+
|
|
132
|
+
- run: ./scripts/smoke.sh
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# The release tag, as a consequence of the merge rather than a step somebody remembers.
|
|
2
|
+
#
|
|
3
|
+
# This repository is squash-only. A tag made on the branch before the merge names a commit that is
|
|
4
|
+
# not on `main` — the branch commit and the squash commit can have the identical tree and the
|
|
5
|
+
# identical parent and still different hashes, because the squash rewrites the message and the
|
|
6
|
+
# committer. Reading the version *after* the merge and tagging the commit that landed is the only
|
|
7
|
+
# way to get this right by construction.
|
|
8
|
+
#
|
|
9
|
+
# It runs on every push to `main`, and on almost all of them it does nothing. That is the design: the
|
|
10
|
+
# common case must be a quiet, green no-op, because a mechanism that costs something on the ordinary
|
|
11
|
+
# path is a mechanism somebody eventually deletes.
|
|
12
|
+
#
|
|
13
|
+
# The release workflow is *called*, never triggered. A tag pushed with GITHUB_TOKEN does not start
|
|
14
|
+
# other workflow runs — GitHub's loop prevention — so a release workflow listening on `push: tags:`
|
|
15
|
+
# would never fire, and would look correct in review. tests/test_workflows.py forbids that shape
|
|
16
|
+
# repository-wide.
|
|
17
|
+
#
|
|
18
|
+
# Publishing is the last job here, and it waits for a person. It hands the index the artifact the
|
|
19
|
+
# release workflow built and checked against the tag in this same run, and it starts only once a
|
|
20
|
+
# reviewer approves the run in the `pypi` deployment environment. That approval is the gate, and it is
|
|
21
|
+
# deliberately not in this file. The job lives here rather than in release.yml because a called
|
|
22
|
+
# workflow can narrow the token its caller grants but never widen it, and the index does not support a
|
|
23
|
+
# reusable workflow as a trusted publisher. So this file is the workflow registered with the index.
|
|
24
|
+
|
|
25
|
+
name: Tag on merge
|
|
26
|
+
|
|
27
|
+
on:
|
|
28
|
+
push:
|
|
29
|
+
branches: [main]
|
|
30
|
+
|
|
31
|
+
# Read by default; each job that needs more says so for itself.
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
tag:
|
|
37
|
+
name: Tag the declared version, if it is new
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
permissions:
|
|
40
|
+
contents: write
|
|
41
|
+
# Not keyed on the ref: every run of this workflow is on `main`, and the point is that two merges
|
|
42
|
+
# landing seconds apart cannot both decide that a tag is missing. Cancelling is wrong here — a
|
|
43
|
+
# cancelled tag job leaves a release untagged with nothing to report it, which is the failure this
|
|
44
|
+
# workflow exists to prevent.
|
|
45
|
+
#
|
|
46
|
+
# On this job rather than on the workflow, because the run ends in a job that waits for a reviewer.
|
|
47
|
+
# At workflow level the group would be held for the whole wait, and GitHub keeps one pending run
|
|
48
|
+
# per group, cancelling it when the next one arrives — so a merge landing during the wait could
|
|
49
|
+
# lose its tag run to the merge after it. Here the group is released before anything waits on a
|
|
50
|
+
# person.
|
|
51
|
+
concurrency:
|
|
52
|
+
group: tag-on-merge
|
|
53
|
+
cancel-in-progress: false
|
|
54
|
+
outputs:
|
|
55
|
+
version: ${{ steps.tag.outputs.version }}
|
|
56
|
+
created: ${{ steps.tag.outputs.created }}
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v6
|
|
59
|
+
with:
|
|
60
|
+
# Brings the tags with it, so the "does this already exist?" question and the tag object
|
|
61
|
+
# itself are one local repository. It also keeps the push off a shallow clone, which git
|
|
62
|
+
# can refuse.
|
|
63
|
+
fetch-depth: 0
|
|
64
|
+
|
|
65
|
+
- id: tag
|
|
66
|
+
run: |
|
|
67
|
+
set -euo pipefail
|
|
68
|
+
|
|
69
|
+
# The same extraction make release-check performs, for the same reason: this is the one
|
|
70
|
+
# place the version is declared, and reading the file as text needs no environment.
|
|
71
|
+
version="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/cassetta/__init__.py | head -n 1)"
|
|
72
|
+
|
|
73
|
+
if [ -z "$version" ]; then
|
|
74
|
+
echo "src/cassetta/__init__.py declares no __version__ — nothing to tag" >&2
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
echo "version=${version}" >>"$GITHUB_OUTPUT"
|
|
79
|
+
|
|
80
|
+
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
|
|
81
|
+
echo "created=false" >>"$GITHUB_OUTPUT"
|
|
82
|
+
printf 'v%s is already tagged — this merge carries no release.\n' "$version"
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
# Annotated, with the tag's own name as the message: that is what v0.26.0 through v0.26.5
|
|
87
|
+
# carry, and a release history where one entry is shaped differently is a question someone
|
|
88
|
+
# has to answer later.
|
|
89
|
+
git config user.name "github-actions[bot]"
|
|
90
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
91
|
+
git tag -a "v${version}" -m "v${version}"
|
|
92
|
+
git push origin "v${version}"
|
|
93
|
+
|
|
94
|
+
echo "created=true" >>"$GITHUB_OUTPUT"
|
|
95
|
+
printf 'tagged %s as v%s\n' "$(git rev-parse --short HEAD)" "$version"
|
|
96
|
+
|
|
97
|
+
release:
|
|
98
|
+
name: Release
|
|
99
|
+
needs: tag
|
|
100
|
+
if: needs.tag.outputs.created == 'true'
|
|
101
|
+
# A call, in this same run — not something the tag push triggers, because it could not be. A job
|
|
102
|
+
# that carries `uses:` carries no `runs-on` and no `steps` of its own.
|
|
103
|
+
uses: ./.github/workflows/release.yml
|
|
104
|
+
with:
|
|
105
|
+
version: ${{ needs.tag.outputs.version }}
|
|
106
|
+
|
|
107
|
+
publish:
|
|
108
|
+
name: Publish the release, once a reviewer approves
|
|
109
|
+
# `release` is what makes this the checked build. When the merge carried no new version `release`
|
|
110
|
+
# is skipped, and this job is skipped with it — the quiet no-op above, not a gate. `tag` is listed
|
|
111
|
+
# as well because the version is read from its outputs, and the `needs` context carries only the
|
|
112
|
+
# jobs named here.
|
|
113
|
+
needs: [tag, release]
|
|
114
|
+
runs-on: ubuntu-latest
|
|
115
|
+
# The gate: a reviewer on this environment approves the run before this job starts, and the name
|
|
116
|
+
# is also what the index checks in the token's claim. There is no `if:`. A false condition would
|
|
117
|
+
# skip the job and paint the run green, and a release that silently did not happen is the failure
|
|
118
|
+
# this whole composition is built to rule out.
|
|
119
|
+
environment: pypi
|
|
120
|
+
# Only what the OIDC exchange needs, and on this job alone. No token, no secret, no `password:` —
|
|
121
|
+
# the publish action trades a short-lived identity token for an upload credential on its own.
|
|
122
|
+
permissions:
|
|
123
|
+
id-token: write
|
|
124
|
+
steps:
|
|
125
|
+
# The bytes release.yml built and checked against the tag, under the name it uploaded them with.
|
|
126
|
+
# Nothing here checks the repository out, so there is nothing to build a second distribution from.
|
|
127
|
+
- uses: actions/download-artifact@v8
|
|
128
|
+
with:
|
|
129
|
+
name: cassetta-${{ needs.tag.outputs.version }}
|
|
130
|
+
path: dist/
|
|
131
|
+
|
|
132
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
|
|
10
|
+
# IDE
|
|
11
|
+
.idea/
|
|
12
|
+
.vscode/
|
|
13
|
+
*.swp
|
|
14
|
+
|
|
15
|
+
# Atomic writes: tooling that writes <path>.tmp then renames it into place
|
|
16
|
+
*.tmp
|
|
17
|
+
|
|
18
|
+
# Testing & Linting
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
htmlcov/
|
|
23
|
+
.coverage
|
|
24
|
+
|
|
25
|
+
# Environment
|
|
26
|
+
.env
|
|
27
|
+
.env.local
|
|
28
|
+
|
|
29
|
+
# Claude Code (may contain credentials)
|
|
30
|
+
.claude/settings.local.json
|
|
31
|
+
|
|
32
|
+
# OS
|
|
33
|
+
.DS_Store
|
|
34
|
+
Thumbs.db
|
|
35
|
+
|
|
36
|
+
# Docker
|
|
37
|
+
data/
|
|
38
|
+
data.keys/
|
|
39
|
+
|
|
40
|
+
# Briefs
|
|
41
|
+
inbox/
|