Skip to content

Commit db5b4d0

Browse files
committed
chore(ci): run compliance harness 1.13.1 once per codec
The adapter now advertises capture_ai_v1 and event_options, passes options and uuids through unchanged, and takes its codec from COMPRESSION. The workflow runs the harness containers directly in a gzip/deflate/zstd matrix, so it no longer needs pull-requests: write.
1 parent 3f09f03 commit db5b4d0

5 files changed

Lines changed: 201 additions & 109 deletions

File tree

‎.github/workflows/sdk-compliance.yml‎

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ name: SDK Compliance Tests
22

33
permissions:
44
contents: read
5-
packages: read
6-
pull-requests: write
75

86
on:
97
pull_request:
@@ -28,11 +26,76 @@ jobs:
2826
run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30
2927

3028
compliance:
31-
name: PostHog SDK compliance tests
32-
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0
33-
with:
34-
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
35-
adapter-context: "."
36-
test-harness-version: "1.1.1"
37-
continue-on-error: false
38-
report-name: "sdk-compliance-report"
29+
# The harness has one enable_compression flag but a test per codec, so each
30+
# job runs the adapter with one codec. The reusable harness workflow cannot
31+
# pass the COMPRESSION env var, so this job runs the containers directly.
32+
# The SDK has no brotli support, so there is no br job.
33+
name: PostHog SDK compliance tests (${{ matrix.compression }})
34+
runs-on: ubuntu-latest
35+
timeout-minutes: 30
36+
strategy:
37+
fail-fast: false
38+
matrix:
39+
compression: [gzip, deflate, zstd]
40+
concurrency:
41+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.compression }}
42+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
43+
env:
44+
HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.13.1
45+
steps:
46+
- name: Check out source code
47+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
48+
49+
- name: Pull test harness
50+
run: docker pull "$HARNESS_IMAGE"
51+
52+
- name: Build SDK adapter
53+
run: docker build -f sdk_compliance_adapter/Dockerfile -t sdk-adapter:test .
54+
55+
- name: Start SDK adapter
56+
run: |
57+
docker network create test-network
58+
docker run -d \
59+
--name sdk-adapter \
60+
--network test-network \
61+
-e COMPRESSION=${{ matrix.compression }} \
62+
sdk-adapter:test
63+
64+
- name: Run compliance suite
65+
run: |
66+
mkdir -p report
67+
docker run --rm \
68+
--name test-harness \
69+
--network test-network \
70+
-v "${GITHUB_WORKSPACE}/report:/report" \
71+
"$HARNESS_IMAGE" \
72+
run \
73+
--adapter-url http://sdk-adapter:8080 \
74+
--mock-url http://test-harness:8081 \
75+
--output text \
76+
--report /report/sdk-compliance-report.md \
77+
--sdk-type server
78+
79+
- name: Publish report to the job summary
80+
if: always()
81+
run: |
82+
if [ -f report/sdk-compliance-report.md ]; then
83+
cat report/sdk-compliance-report.md >> "$GITHUB_STEP_SUMMARY"
84+
fi
85+
86+
- name: Upload compliance report
87+
if: always()
88+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
89+
with:
90+
name: sdk-compliance-report-${{ matrix.compression }}
91+
path: report/sdk-compliance-report.md
92+
93+
- name: Show adapter logs on failure
94+
if: failure()
95+
run: docker logs sdk-adapter || true
96+
97+
- name: Stop containers
98+
if: always()
99+
run: |
100+
docker rm -f sdk-adapter || true
101+
docker network rm test-network || true

‎sdk_compliance_adapter/Dockerfile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ WORKDIR /app
66
COPY posthog/ /app/sdk/posthog/
77
COPY setup.py pyproject.toml README.md LICENSE /app/sdk/
88

9-
# Install the SDK from source
10-
RUN cd /app/sdk && pip install --no-cache-dir -e .
9+
# Install the SDK from source, with zstd for the zstd compliance job
10+
RUN cd /app/sdk && pip install --no-cache-dir -e ".[zstd]"
1111

1212
# Install adapter dependencies
1313
RUN pip install --no-cache-dir flask python-dateutil

‎sdk_compliance_adapter/adapter.py‎

Lines changed: 72 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@
2828

2929
app = Flask(__name__)
3030

31+
# The harness has one enable_compression flag but a test per codec, so each
32+
# adapter instance takes its codec from COMPRESSION and advertises only that
33+
# codec's encoding_<codec> capability. The SDK has no brotli support.
34+
COMPRESSION_CODECS = {
35+
"gzip": CaptureCompression.GZIP,
36+
"deflate": CaptureCompression.DEFLATE,
37+
"zstd": CaptureCompression.ZSTD,
38+
}
39+
40+
41+
def compression_name() -> str:
42+
return os.environ.get("COMPRESSION") or "gzip"
43+
3144

3245
class RequestInfo:
3346
"""Information about an HTTP request made by the SDK"""
@@ -220,11 +233,11 @@ def patched_post_v1(
220233
@app.route("/health", methods=["GET"])
221234
def health():
222235
"""Health check endpoint"""
223-
# No AI capture capability: `capture_ai` posts capture v1 to
224-
# /i/v1/ai/events, which this harness version has no suite for.
225236
capabilities = [
226237
"capture_v1",
227-
"encoding_gzip",
238+
"capture_ai_v1",
239+
"event_options",
240+
"encoding_" + compression_name(),
228241
"feature_flags_local_evaluation_v1",
229242
]
230243
return jsonify(
@@ -267,18 +280,20 @@ def init():
267280
# Convert flush_interval from ms to seconds
268281
flush_interval = flush_interval_ms / 1000.0
269282

283+
compression = (
284+
COMPRESSION_CODECS[compression_name()]
285+
if enable_compression
286+
else CaptureCompression.NONE
287+
)
270288
# Explicit reloads exercise the real loader without background polling
271289
# racing the harness's per-test definition snapshots.
272290
client_options = {
273291
"project_api_key": api_key,
274292
"host": host,
275293
"flush_at": flush_at,
276294
"flush_interval": flush_interval,
277-
"capture_compression": (
278-
CaptureCompression.GZIP
279-
if enable_compression
280-
else CaptureCompression.NONE
281-
),
295+
"capture_compression": compression,
296+
"capture_ai_compression": compression,
282297
"max_retries": max_retries,
283298
"debug": False,
284299
"disable_geoip": disable_geoip,
@@ -308,56 +323,55 @@ def init():
308323
return jsonify({"error": str(e)}), 500
309324

310325

311-
@app.route("/capture", methods=["POST"])
312-
def capture():
313-
"""Capture a single event"""
314-
try:
315-
if not state.client:
316-
return jsonify({"error": "SDK not initialized"}), 400
326+
def _capture_with(capture_method_name: str):
327+
"""Capture one event through the named SDK method.
317328
318-
data = request.json or {}
329+
Options pass through unchanged, and a supplied uuid stays on the wire. The
330+
response carries the uuid the SDK actually sent, which differs from the
331+
supplied one when the SDK canonicalizes or replaces it.
332+
"""
333+
if not state.client:
334+
return jsonify({"error": "SDK not initialized"}), 400
319335

320-
distinct_id = data.get("distinct_id")
321-
event = data.get("event")
322-
properties = data.get("properties")
323-
timestamp = data.get("timestamp")
324-
options = data.get("options")
336+
data = request.json or {}
325337

326-
if not distinct_id:
327-
return jsonify({"error": "distinct_id is required"}), 400
328-
if not event:
329-
return jsonify({"error": "event is required"}), 400
330-
331-
# Fold capture-v1 options back into the magic `$`-prefixed properties the
332-
# SDK lifts onto the wire `options` object. Renamed keys mirror the SDK's
333-
# sentinel table; unknown keys get a bare `$` prefix.
334-
if options:
335-
properties = dict(properties or {})
336-
option_to_property = {
337-
"cookieless_mode": "$cookieless_mode",
338-
"disable_skew_correction": "$ignore_sent_at",
339-
"process_person_profile": "$process_person_profile",
340-
"product_tour_id": "$product_tour_id",
341-
}
342-
for key, value in options.items():
343-
properties[option_to_property.get(key, "$" + key)] = value
338+
distinct_id = data.get("distinct_id")
339+
event = data.get("event")
340+
timestamp = data.get("timestamp")
341+
342+
if not distinct_id:
343+
return jsonify({"error": "distinct_id is required"}), 400
344+
if not event:
345+
return jsonify({"error": "event is required"}), 400
346+
347+
kwargs = {
348+
"distinct_id": distinct_id,
349+
"properties": data.get("properties"),
350+
"options": data.get("options"),
351+
}
352+
if timestamp:
353+
# Parse ISO8601 timestamp
354+
from dateutil.parser import parse
344355

345-
# Capture event
346-
kwargs = {"distinct_id": distinct_id, "properties": properties}
347-
if timestamp:
348-
# Parse ISO8601 timestamp
349-
from dateutil.parser import parse
356+
kwargs["timestamp"] = parse(timestamp)
357+
if data.get("uuid"):
358+
kwargs["uuid"] = data["uuid"]
350359

351-
kwargs["timestamp"] = parse(timestamp)
360+
uuid = getattr(state.client, capture_method_name)(event, **kwargs)
352361

353-
uuid = state.client.capture(event, **kwargs)
362+
# Track that we captured an event
363+
state.increment_captured()
354364

355-
# Track that we captured an event
356-
state.increment_captured()
365+
logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}")
357366

358-
logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}")
367+
return jsonify({"success": True, "uuid": uuid})
359368

360-
return jsonify({"success": True, "uuid": uuid})
369+
370+
@app.route("/capture", methods=["POST"])
371+
def capture():
372+
"""Capture a single event"""
373+
try:
374+
return _capture_with("capture")
361375
except Exception as e:
362376
logger.exception("Error capturing event")
363377
state.record_error(str(e))
@@ -368,50 +382,7 @@ def capture():
368382
def capture_ai():
369383
"""Capture a single AI event on the dedicated AI capture endpoint"""
370384
try:
371-
if not state.client:
372-
return jsonify({"error": "SDK not initialized"}), 400
373-
374-
data = request.json or {}
375-
376-
distinct_id = data.get("distinct_id")
377-
event = data.get("event")
378-
properties = data.get("properties")
379-
timestamp = data.get("timestamp")
380-
options = data.get("options")
381-
supplied_uuid = data.get("uuid")
382-
383-
if not distinct_id:
384-
return jsonify({"error": "distinct_id is required"}), 400
385-
if not event:
386-
return jsonify({"error": "event is required"}), 400
387-
388-
if options:
389-
properties = dict(properties or {})
390-
option_to_property = {
391-
"cookieless_mode": "$cookieless_mode",
392-
"disable_skew_correction": "$ignore_sent_at",
393-
"process_person_profile": "$process_person_profile",
394-
"product_tour_id": "$product_tour_id",
395-
}
396-
for key, value in options.items():
397-
properties[option_to_property.get(key, "$" + key)] = value
398-
399-
kwargs = {"distinct_id": distinct_id, "properties": properties}
400-
if timestamp:
401-
from dateutil.parser import parse
402-
403-
kwargs["timestamp"] = parse(timestamp)
404-
# Unlike /capture, forward a supplied uuid so it's echoed back to the caller.
405-
if supplied_uuid:
406-
kwargs["uuid"] = supplied_uuid
407-
408-
uuid = state.client.capture_ai(event, **kwargs)
409-
410-
state.increment_captured()
411-
412-
logger.info(f"Captured AI event: {event} for {distinct_id}, uuid={uuid}")
413-
414-
return jsonify({"success": True, "uuid": uuid})
385+
return _capture_with("capture_ai")
415386
except Exception as e:
416387
logger.exception("Error capturing AI event")
417388
state.record_error(str(e))
@@ -638,7 +609,14 @@ def reset():
638609
def main():
639610
"""Main entry point"""
640611
port = int(os.environ.get("PORT", 8080))
641-
logger.info(f"Starting SDK Test Adapter on port {port}")
612+
if compression_name() not in COMPRESSION_CODECS:
613+
raise SystemExit(
614+
f"unsupported COMPRESSION {compression_name()!r}: "
615+
f"want one of {', '.join(COMPRESSION_CODECS)}"
616+
)
617+
logger.info(
618+
f"Starting SDK Test Adapter on port {port} (compression={compression_name()})"
619+
)
642620
app.run(host="0.0.0.0", port=port, debug=False)
643621

644622

‎sdk_compliance_adapter/docker-compose.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ services:
66
build:
77
context: ..
88
dockerfile: sdk_compliance_adapter/Dockerfile
9+
environment:
10+
# One of gzip, deflate or zstd; selects the encoding_<codec> test.
11+
COMPRESSION: ${COMPRESSION:-gzip}
912
ports:
1013
- "8080:8080"
1114
networks:
1215
- test-network
1316

1417
# Test harness
1518
test-harness:
16-
image: ghcr.io/posthog/sdk-test-harness:1.1.1
19+
image: ghcr.io/posthog/sdk-test-harness:1.13.1
1720
command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"]
1821
networks:
1922
- test-network

0 commit comments

Comments
 (0)