Skip to content

fix(reporting): validate buyer frozen histories #2769

fix(reporting): validate buyer frozen histories

fix(reporting): validate buyer frozen histories #2769

Workflow file for this run

name: CI
on:
push:
branches: [main, python-adcp-sdk-setup]
pull_request:
branches:
- main
- conductor/reporting-webhook-activity-1168b
- conductor/reporting-status-notifications-1168c
- conductor/1167b1-materializer-contracts
- conductor/1167b2-durable-managed-reporting
- conductor/reporting-receipt-ingress-b22
- conductor/reporting-frozen-account-feed-b23
- conductor/reporting-schema-proof-receipt-diagnostics-hardening
- conductor/reporting-production-tier-capabilities-b24
- conductor/reporting-adcp-rc4-adoption
# Default @adcp/sdk runner alias for storyboard jobs. Tracks the current
# stable @adcp/sdk release via the ``latest`` npm dist-tag.
env:
ADCP_SDK_VERSION: "latest"
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
workflow-security:
name: Workflow security
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Lint workflows
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
with:
version: 1.7.7
shellcheck: false
# actionlint v1.7.x predates GitHub's `queue: max` concurrency
# syntax. Keep linting every other workflow field until its parser
# catches up.
flags: >-
-ignore ^unexpected\s+key\s+\x22queue\x22\s+for\s+\x22concurrency\x22\s+section
- name: Test Ladon review-state helper
run: node --test scripts/retire-superseded-ladon-reviews.test.cjs
- name: Audit workflows
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with:
inputs: >-
.github/workflows/claude-issue-triage.yml
.github/workflows/ipr-agreement.yml
.github/workflows/sync-agent-roles.yml
.github/workflows/slash-command-dispatch.yml
.github/workflows/ai-review.yml
.github/workflows/release-please.yml
.github/workflows/release-proposal.yml
.github/workflows/release-acceptance.yml
.github/workflows/release-publish.yml
version: 1.29.0
min-severity: high
min-confidence: high
advanced-security: false
annotations: true
test:
name: Test Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
# The job ceiling is not the suite's budget: it also has to absorb
# checkout, Python setup, the editable [dev] install and, on 3.12 only,
# ruff/mypy/mypy --strict/the type-ignore contract before pytest starts,
# then post-job cleanup after it ends. On an ubuntu-latest runner that
# pre-test work is ~1m30s and the plain suite is ~16m, but 3.12 adds
# coverage tracing on ~9.4k tests: at 30 minutes that leg was cancelled
# mid-run at 99% with no failing test, which reports as a red matrix and
# hides real signal. Bound the suite itself below (so a hang fails one
# named step with its own message instead of silently taking the job), and
# leave the job enough room that step bound plus setup and cleanup still
# fit with margin for runner variance.
timeout-minutes: 60
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run linter
if: matrix.python-version == '3.12'
run: ruff check src/
- name: Run type checker
if: matrix.python-version == '3.12'
run: mypy src/adcp/
- name: Run adopter type-check suite
if: matrix.python-version == '3.12'
run: mypy --strict tests/type_checks/ examples/reporting_webhook_activity.py examples/reporting_status_notifications.py examples/reporting_destination_writer.py examples/reporting_durable_materializer.py examples/reporting_receipt_ingress.py examples/reporting_production.py
- name: Enforce adopter type-check fixture contract
if: matrix.python-version == '3.12'
run: python scripts/check_type_ignore_contract.py
# Bounded well above the observed ~16m (plain) and ~30m (coverage) runs
# so ordinary variance never trips it, and well below the job ceiling so
# a genuinely stuck suite still fails *this* step with a timeout rather
# than being cancelled as a whole job.
- name: Run tests
if: matrix.python-version != '3.12'
timeout-minutes: 45
run: python scripts/reporting_test_harness.py pytest tests/ -v -ra
- name: Run tests with coverage
if: matrix.python-version == '3.12'
timeout-minutes: 45
run: python scripts/reporting_test_harness.py pytest tests/ -v -ra --cov=src/adcp --cov-report=term-missing
pg-conformance:
name: Postgres conformance tests (Postgres 16, ${{ matrix.lane }})
runs-on: ubuntu-latest
# The core lane was still passing at 90% when its 15-minute job expired.
# Leave room for dependency installation and the complete conformance suite.
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
lane: [core, process]
services:
postgres:
# CI-local ephemeral database. POSTGRES_HOST_AUTH_METHOD=trust
# avoids shipping any password literal (real or placeholder) in
# this workflow — GitHub's default CI network is already the
# trust boundary for this throwaway service.
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Fetch reviewed reporting baseline for rolling binary tests
timeout-minutes: 1
run: git fetch --no-tags --depth=1 origin 17ee407ae3978c8a2bb54437287afbf9dafb8130
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies (with [pg] extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,pg]"
- name: Run Postgres conformance tests
timeout-minutes: 25
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_test
PG_LANE: ${{ matrix.lane }}
run: |
# Keep every case and its per-test deadline. Separate process-crash
# controls so a test timeout is reported before the job deadline.
case "$PG_LANE" in
core)
python scripts/reporting_test_harness.py pytest tests/conformance/signing/test_pg_replay_store.py \
tests/conformance/signing/test_pg_replay_store_e2e.py \
tests/conformance/decisioning/test_pg_buyer_agent_registry.py \
tests/conformance/decisioning/test_pg_idempotency_backend.py \
tests/conformance/decisioning/test_pg_task_webhook_outbox.py \
tests/conformance/decisioning/test_pg_reference_workflow_queue.py \
tests/conformance/reporting/ \
--ignore=tests/conformance/reporting/test_reporting_notification_process_matrix.py \
--ignore-glob='tests/conformance/reporting/test_reporting_status_*.py' \
--ignore=tests/conformance/reporting/test_reporting_materializer_rolling.py \
--ignore=tests/conformance/reporting/test_reporting_materializer_process.py \
--ignore=tests/conformance/reporting/test_reporting_materializer_migration.py \
--ignore=tests/conformance/reporting/test_reporting_materializer_installed_pg.py \
--ignore-glob='tests/conformance/reporting/test_reporting_receipt_*.py' \
--ignore-glob='tests/conformance/reporting/test_reporting_feed_*.py' \
--ignore-glob='tests/conformance/reporting/test_reporting_production*.py' \
--ignore-glob='tests/conformance/reporting/test_reporting_projection*.py' \
--ignore=tests/conformance/reporting/test_reporting_tier_projection.py \
--ignore=tests/conformance/reporting/test_reporting_schedule_schema.py \
--ignore=tests/conformance/reporting/test_reporting_rc6_rolling.py \
-v -ra
;;
process)
python scripts/reporting_test_harness.py pytest tests/conformance/reporting/test_reporting_notification_process_matrix.py -v -ra
;;
*)
echo "Unknown Postgres conformance lane"
exit 1
;;
esac
pg-conformance-required-gate:
name: Postgres conformance tests (Postgres 16)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
needs: [pg-conformance, pg-reporting-status, pg-reporting-materializer, pg-reporting-receipts, pg-reporting-receipt-compatibility, pg-reporting-feed, pg-reporting-feed-compatibility, pg-reporting-feed-installed, pg-reporting-production, pg-reporting-production-compatibility, pg-reporting-production-installed]
if: ${{ always() }}
steps:
- name: Require every Postgres conformance lane
env:
PG_RESULT: ${{ needs.pg-conformance.result }}
STATUS_RESULT: ${{ needs.pg-reporting-status.result }}
MATERIALIZER_RESULT: ${{ needs.pg-reporting-materializer.result }}
RECEIPTS_RESULT: ${{ needs.pg-reporting-receipts.result }}
RECEIPT_COMPATIBILITY_RESULT: ${{ needs.pg-reporting-receipt-compatibility.result }}
FEED_RESULT: ${{ needs.pg-reporting-feed.result }}
FEED_COMPATIBILITY_RESULT: ${{ needs.pg-reporting-feed-compatibility.result }}
FEED_INSTALLED_RESULT: ${{ needs.pg-reporting-feed-installed.result }}
PRODUCTION_RESULT: ${{ needs.pg-reporting-production.result }}
PRODUCTION_COMPATIBILITY_RESULT: ${{ needs.pg-reporting-production-compatibility.result }}
PRODUCTION_INSTALLED_RESULT: ${{ needs.pg-reporting-production-installed.result }}
run: |
if [ "$PG_RESULT" != "success" ] || [ "$STATUS_RESULT" != "success" ] || [ "$MATERIALIZER_RESULT" != "success" ] || [ "$RECEIPTS_RESULT" != "success" ] || [ "$RECEIPT_COMPATIBILITY_RESULT" != "success" ] || [ "$FEED_RESULT" != "success" ] || [ "$FEED_COMPATIBILITY_RESULT" != "success" ] || [ "$FEED_INSTALLED_RESULT" != "success" ] || [ "$PRODUCTION_RESULT" != "success" ] || [ "$PRODUCTION_COMPATIBILITY_RESULT" != "success" ] || [ "$PRODUCTION_INSTALLED_RESULT" != "success" ]; then
echo "Postgres conformance matrix result: $PG_RESULT"
echo "Reporting status conformance result: $STATUS_RESULT"
echo "Durable materializer conformance result: $MATERIALIZER_RESULT"
echo "Authenticated receipt conformance result: $RECEIPTS_RESULT"
echo "Receipt rolling compatibility result: $RECEIPT_COMPATIBILITY_RESULT"
echo "Frozen feed conformance result: $FEED_RESULT"
echo "Feed rolling compatibility result: $FEED_COMPATIBILITY_RESULT"
echo "Installed frozen feed result: $FEED_INSTALLED_RESULT"
echo "pg-reporting-production result: $PRODUCTION_RESULT"
echo "pg-reporting-production-compatibility result: $PRODUCTION_COMPATIBILITY_RESULT"
echo "pg-reporting-production-installed result: $PRODUCTION_INSTALLED_RESULT"
exit 1
fi
echo "All Postgres conformance lanes passed"
pg-reporting-status:
name: Reporting status conformance (Postgres 16)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_status_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Fetch exact reviewed A, B and C compatibility artifacts
timeout-minutes: 1
run: |
git fetch --no-tags --depth=1 origin \
17ee407ae3978c8a2bb54437287afbf9dafb8130 \
0f34c666ac1961e9832fce43ef0ef6937b3c1dde \
967b6e286301d7e5d089aea6fdbb90bea8ee5a16
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies (with [pg] extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,pg]"
- name: Run status semantics, transaction, clock/process and rolling gates
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_status_test
run: python scripts/reporting_test_harness.py pytest tests/conformance/reporting/test_reporting_status_*.py -v -ra
pg-reporting-materializer:
name: Durable materializer and frozen artifacts (Postgres 16)
runs-on: ubuntu-latest
# Actual wheel builds/installations plus isolated worker processes are a
# separate bounded job; do not consume the existing conformance headroom.
# Seven real installations plus process/crash and Python 3.10 distribution
# cells approach twenty minutes locally; allow runner variance and retain
# five minutes outside the suite for setup and cleanup.
timeout-minutes: 35
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_materializer_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Fetch exact frozen reporting artifacts
timeout-minutes: 2
run: |
git fetch --no-tags --depth=1 origin \
3e76aa54623529a3dda01cd690b8a5c287c75641 \
3c405a21f978ed9d3208611bb4a7a8434a056933 \
037de4ac822ecefb2f95d32c15c297fb4c45d683 \
17ee407ae3978c8a2bb54437287afbf9dafb8130 \
0f34c666ac1961e9832fce43ef0ef6937b3c1dde \
967b6e286301d7e5d089aea6fdbb90bea8ee5a16 \
5487f2bdef23c5102118b305be9e868228f6ce61
- uses: actions/setup-python@v6
id: materializer-python310
with:
python-version: "3.10"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run installed frozen binaries and materializer migrations
# Propagate pytest failures through tee while retaining evidence for upload.
shell: bash
timeout-minutes: 30
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_materializer_test
ADCP_PYTHON310: ${{ steps.materializer-python310.outputs.python-path }}
run: |
python scripts/reporting_test_harness.py pytest \
tests/conformance/reporting/test_reporting_materializer_rolling.py \
tests/conformance/reporting/test_reporting_materializer_migration.py \
tests/conformance/reporting/test_reporting_materializer_process.py \
tests/conformance/reporting/test_reporting_materializer_installed_pg.py \
-v -s -ra | tee materializer-evidence.log
- name: Preserve installed module and worker evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: materializer-evidence-${{ github.run_attempt }}
path: materializer-evidence.log
if-no-files-found: error
pg-reporting-receipts:
name: Authenticated receipts and installed replay (Postgres 16)
runs-on: ubuntu-latest
# Separate databases avoid shared account advisory locks. Keep five minutes
# outside the bounded suite for checkout, installations and cleanup.
timeout-minutes: 35
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_receipts_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
id: receipt-python310
with:
python-version: "3.10"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run receipt transactions, real mounts, crash and installed gates
shell: bash
timeout-minutes: 30
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_receipts_test
ADCP_PYTHON310: ${{ steps.receipt-python310.outputs.python-path }}
run: |
# Explicit pytest paths bypass --ignore; exclude rolling before invocation.
shopt -s nullglob
receipt_tests=()
for test_file in tests/conformance/reporting/test_reporting_receipt_*.py; do
if [[ "$test_file" != tests/conformance/reporting/test_reporting_receipt_rolling.py ]]; then
receipt_tests+=("$test_file")
fi
done
if (( ${#receipt_tests[@]} == 0 )); then
echo "No receipt test files selected" >&2
exit 1
fi
python scripts/reporting_test_harness.py pytest \
"${receipt_tests[@]}" \
-v -s -ra | tee pg-reporting-receipts-evidence.log
- name: Preserve commands, counts, installed provenance and skip reasons
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-receipts-evidence-${{ github.run_attempt }}
path: pg-reporting-receipts-evidence.log
if-no-files-found: error
pg-reporting-receipt-compatibility-shard:
name: Receipt rolling shard (${{ matrix.artifact }})
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
strategy:
fail-fast: false
matrix:
artifact: [beta15, records, integration, a, b, c, b1, b21]
env:
RECEIPT_ARTIFACT: ${{ matrix.artifact }}
RECEIPT_SOURCE_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_receipt_rolling_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
# runner.temp survives checkout cleanup. Setup failures still identify the
# attempted shard; an incomplete artifact can never satisfy the aggregate.
- name: Record attempted shard identity before checkout
timeout-minutes: 1
shell: bash
run: |
python3 - <<'PYTHON'
import json
import os
import re
from pathlib import Path
root = Path(os.environ["RUNNER_TEMP"]) / "receipt-rolling-evidence"
root.mkdir(mode=0o700)
fields = {
"source_head": "RECEIPT_SOURCE_HEAD", "event_sha": "GITHUB_SHA",
"event_name": "GITHUB_EVENT_NAME", "run_id": "GITHUB_RUN_ID",
"run_attempt": "GITHUB_RUN_ATTEMPT", "repository": "GITHUB_REPOSITORY",
"artifact": "RECEIPT_ARTIFACT",
}
values = {key: os.environ[value] for key, value in fields.items()}
valid = (
all(re.fullmatch(r"[0-9a-f]{40}", values[k]) for k in ("source_head", "event_sha"))
and all(re.fullmatch(r"[1-9][0-9]{0,19}", values[k]) for k in ("run_id", "run_attempt"))
and values["repository"] == "adcontextprotocol/adcp-client-python"
and values["event_name"] in {"pull_request", "push"}
and values["artifact"] in {"beta15", "records", "integration", "a", "b", "c", "b1", "b21"}
and (values["event_name"] != "push" or values["source_head"] == values["event_sha"])
)
if not valid:
raise SystemExit("invalid shard attempt identity")
(root / "attempted.json").write_text(json.dumps(values) + "\n")
PYTHON
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
timeout-minutes: 2
with:
ref: ${{ env.RECEIPT_SOURCE_HEAD }}
persist-credentials: false
- name: Fetch all eight exact approved historical artifacts
timeout-minutes: 2
run: |
git fetch --no-tags --depth=1 origin \
3e76aa54623529a3dda01cd690b8a5c287c75641 \
3c405a21f978ed9d3208611bb4a7a8434a056933 \
037de4ac822ecefb2f95d32c15c297fb4c45d683 \
17ee407ae3978c8a2bb54437287afbf9dafb8130 \
0f34c666ac1961e9832fce43ef0ef6937b3c1dde \
967b6e286301d7e5d089aea6fdbb90bea8ee5a16 \
5487f2bdef23c5102118b305be9e868228f6ce61 \
3fd62121c96a074e3ea458c30c5224d6a586f169
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
timeout-minutes: 2
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test and workflow-control dependencies
timeout-minutes: 5
run: pip install -e ".[dev,pg]" "PyYAML==6.0.3"
# The unchanged single node includes its notification branches and all
# builds/migrations/recovery. Inner timeout is 15m plus <=30s cleanup;
# all step ceilings total 33m, below this job's unchanged 35m ceiling.
- name: Verify collection and run one complete historical parameter
shell: bash
timeout-minutes: 18
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_receipt_rolling_test
run: |
timeout --signal=TERM --kill-after=5s 60s python -m unittest discover -s scripts -p test_receipt_rolling_ci.py -v
python scripts/receipt_rolling_ci.py run
- name: Preserve safe partial output and exact shard result
if: always()
timeout-minutes: 3
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: receipt-rolling-shard-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.artifact }}
path: ${{ runner.temp }}/receipt-rolling-evidence/
if-no-files-found: error
pg-reporting-receipt-compatibility:
name: Receipt rolling compatibility (eight actual artifacts)
needs: pg-reporting-receipt-compatibility-shard
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 8
permissions:
contents: read
actions: read
env:
RECEIPT_SOURCE_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
timeout-minutes: 2
with:
ref: ${{ env.RECEIPT_SOURCE_HEAD }}
persist-credentials: false
- name: Download this attempt's unmerged shard directories
id: shard-download
timeout-minutes: 3
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: receipt-rolling-shard-${{ github.run_id }}-${{ github.run_attempt }}-*
path: receipt-rolling-aggregate
merge-multiple: false
digest-mismatch: error
# Hard job cancellation can prevent an always-upload step. Missing,
# incomplete, duplicate or mismatched evidence is deliberately fatal.
- name: Require every shard and verify exact run evidence
if: ${{ always() && !cancelled() }}
timeout-minutes: 2
env:
GH_TOKEN: ${{ github.token }}
RECEIPT_MATRIX_RESULT: ${{ needs.pg-reporting-receipt-compatibility-shard.result }}
RECEIPT_DOWNLOAD_RESULT: ${{ steps.shard-download.outcome }}
run: python scripts/receipt_rolling_ci.py aggregate
pg-reporting-feed:
name: Frozen authorized feed and mounted isolation (Postgres 16)
runs-on: ubuntu-latest
# Crash processes and shared memory/SQL vectors have their own database
# and bounded suite; setup and cleanup have five minutes of headroom.
timeout-minutes: 25
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_feed_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run frozen feeds, checkpoints, mounts, transaction faults and migrations
shell: bash
timeout-minutes: 20
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_feed_test
run: |
# Filter before pytest receives explicit paths; --ignore cannot do this.
shopt -s nullglob
feed_tests=()
for test_file in tests/conformance/reporting/test_reporting_feed_*.py; do
case "$test_file" in
tests/conformance/reporting/test_reporting_feed_rolling.py|tests/conformance/reporting/test_reporting_feed_packaging.py|tests/conformance/reporting/test_reporting_feed_installed_pg.py|tests/conformance/reporting/test_reporting_feed_hardening_installed.py) ;;
*) feed_tests+=("$test_file") ;;
esac
done
if (( ${#feed_tests[@]} == 0 )); then
echo "No native feed test files selected" >&2
exit 1
fi
python scripts/reporting_test_harness.py pytest \
"${feed_tests[@]}" \
-v -s -ra | tee pg-reporting-feed-evidence.log
- name: Preserve exact public, private, restart and fault evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-feed-evidence-${{ github.run_attempt }}
path: pg-reporting-feed-evidence.log
if-no-files-found: error
pg-reporting-feed-compatibility:
name: Feed rolling compatibility (nine actual artifacts)
runs-on: ubuntu-latest
# Actual B2.1 produces the artifact and actual B2.2 mounts receipts on
# both sides of migration for every historical reader/writer cell.
timeout-minutes: 50
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_feed_rolling_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Fetch all nine exact approved historical artifacts
timeout-minutes: 2
run: |
git fetch --no-tags --depth=1 origin \
3e76aa54623529a3dda01cd690b8a5c287c75641 \
3c405a21f978ed9d3208611bb4a7a8434a056933 \
037de4ac822ecefb2f95d32c15c297fb4c45d683 \
17ee407ae3978c8a2bb54437287afbf9dafb8130 \
0f34c666ac1961e9832fce43ef0ef6937b3c1dde \
967b6e286301d7e5d089aea6fdbb90bea8ee5a16 \
5487f2bdef23c5102118b305be9e868228f6ce61 \
3fd62121c96a074e3ea458c30c5224d6a586f169 \
09fd87f79a746665d828dea66b3a1dd9d1fc189e
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run historical binaries before and after the isolated feed migration
shell: bash
timeout-minutes: 45
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_feed_rolling_test
run: |
python scripts/reporting_test_harness.py pytest \
tests/conformance/reporting/test_reporting_feed_rolling.py \
-v -s -ra | tee pg-reporting-feed-compatibility-evidence.log
- name: Preserve exact artifact origins, catalogs and original replay responses
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-feed-compatibility-evidence-${{ github.run_attempt }}
path: pg-reporting-feed-compatibility-evidence.log
if-no-files-found: error
pg-reporting-feed-installed:
name: Installed frozen feed (Python 3.10 VCS and sdist)
runs-on: ubuntu-latest
timeout-minutes: 50
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_feed_installed_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Fetch the exact integrated B2.3 hardening comparison artifact
timeout-minutes: 1
run: git fetch --no-tags origin 2d777ace7b4bf8be519ce0abd4fd0a25ed4f1da7
- uses: actions/setup-python@v6
id: feed-python310
with:
python-version: "3.10"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run installed base and PostgreSQL restart cells
shell: bash
timeout-minutes: 40
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_feed_installed_test
ADCP_PYTHON310: ${{ steps.feed-python310.outputs.python-path }}
ADCP_HARDENING_EVIDENCE: ${{ runner.temp }}/hardening-installed-evidence
run: |
python scripts/reporting_test_harness.py pytest \
tests/conformance/reporting/test_reporting_feed_packaging.py \
tests/conformance/reporting/test_reporting_feed_installed_pg.py \
tests/conformance/reporting/test_reporting_feed_hardening_installed.py \
-v -s -ra | tee pg-reporting-feed-installed-evidence.log
- name: Preserve installed origins, SQL, strict adopter and cold page evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-feed-installed-evidence-${{ github.run_attempt }}
path: |
pg-reporting-feed-installed-evidence.log
${{ runner.temp }}/hardening-installed-evidence
if-no-files-found: error
pg-reporting-production:
name: Production reporting status, ownership and notification contracts
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 35
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_production_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s
--health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run complete production and projection conformance
shell: bash
timeout-minutes: 30
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_production_test
run: |
# Explicit pytest paths bypass --ignore; preselect and reject empty globs.
shopt -s nullglob
source_tests=()
for test in tests/conformance/reporting/test_reporting_production*.py; do
case "$test" in
*test_reporting_production_packaging.py|*test_reporting_production_rolling.py) continue ;;
esac
source_tests+=("$test")
done
projection_tests=(tests/conformance/reporting/test_reporting_projection*.py)
if (( ${#source_tests[@]} == 0 || ${#projection_tests[@]} == 0 )); then
echo "Production or projection test selection is empty"
exit 1
fi
python scripts/reporting_test_harness.py pytest \
"${source_tests[@]}" \
"${projection_tests[@]}" \
tests/conformance/reporting/test_reporting_tier_projection.py \
tests/conformance/reporting/test_reporting_schedule_schema.py \
tests/test_reporting_capability_models.py \
tests/test_reporting_revision_ownership.py \
tests/test_reporting_production_public.py \
tests/test_schema_datetime_formats.py \
tests/test_rc6_adoption.py \
-v -s -ra | tee pg-reporting-production-evidence.log
- name: Preserve production contract evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-production-evidence-${{ github.run_attempt }}
path: pg-reporting-production-evidence.log
if-no-files-found: error
pg-reporting-production-installed:
name: Installed production reporting (${{ matrix.cell }}, Python 3.10)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
cell: [base-vcs, base-sdist, pg-vcs, pg-sdist]
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_production_installed_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s
--health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
id: production-python310
with:
python-version: "3.10"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run installed source-equivalent production contract
shell: bash
timeout-minutes: 35
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_production_installed_test
ADCP_PYTHON310: ${{ steps.production-python310.outputs.python-path }}
ADCP_PRODUCTION_EVIDENCE: ${{ runner.temp }}/production-installed-evidence
PRODUCTION_CELL: ${{ matrix.cell }}
run: |
python scripts/reporting_test_harness.py pytest \
tests/conformance/reporting/test_reporting_production_packaging.py \
-k "$PRODUCTION_CELL" -v -s -ra | tee pg-reporting-production-installed.log
- name: Preserve installed origins, original inner logs and exact asset hashes
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-production-installed-${{ matrix.cell }}-${{ github.run_attempt }}
path: |
pg-reporting-production-installed.log
${{ runner.temp }}/production-installed-evidence
if-no-files-found: error
pg-reporting-production-compatibility:
name: B2.3 and hardening to installed B2.4 activation and restart
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 50
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp_production_rolling_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s
--health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v6
- name: Fetch integrated feed, hardening and production comparison artifacts
timeout-minutes: 1
run: git fetch --no-tags origin 2d777ace7b4bf8be519ce0abd4fd0a25ed4f1da7 e16eb8cf3074cabd45aab42840950f05ad6d2b43 34c8f6d929aeac3407e2f595104a8e903e572623
- uses: actions/setup-python@v6
id: production-python310
with:
python-version: "3.10"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install test dependencies
run: pip install -e ".[dev,pg]"
- name: Run actual historical page one, activation, SIGKILL and cold continuations
shell: bash
timeout-minutes: 42
env:
ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_production_rolling_test
ADCP_PYTHON310: ${{ steps.production-python310.outputs.python-path }}
ADCP_PRODUCTION_EVIDENCE: ${{ runner.temp }}/production-rolling-evidence
run: |
python scripts/reporting_test_harness.py pytest \
tests/conformance/reporting/test_reporting_production_rolling.py \
tests/conformance/reporting/test_reporting_rc6_rolling.py \
-v -s -ra | tee pg-reporting-production-rolling.log
- name: Preserve exact installed historical continuity and fence evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: pg-reporting-production-rolling-${{ github.run_attempt }}
path: |
pg-reporting-production-rolling.log
${{ runner.temp }}/production-rolling-evidence
if-no-files-found: error
conventional-commits:
name: Validate conventional commit format
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate PR commits
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate individual commits
run: |
# Get the base branch
BASE_SHA=$(git merge-base origin/${{ github.base_ref }} HEAD)
# Check each commit since the base
echo "Validating commits since $BASE_SHA..."
git log --format="%H %s" $BASE_SHA..HEAD | while read sha message; do
# Skip merge commits. Three GitHub/git-created shapes:
# - "Merge <sha> into <sha>" — the merge-queue API path
# (clicking "Update branch" on a PR)
# - "Merge branch '<name>' [into <name>]" — `gh pr update-branch`
# and `git merge <local-branch>` defaults
# - "Merge remote-tracking branch '<name>' [into <name>]" —
# `git merge origin/<branch>` default
if echo "$message" | grep -qE "^Merge ([0-9a-f]+ into [0-9a-f]+|(remote-tracking )?branch '[^']+')"; then
echo "⊙ Skipping merge commit: $sha"
continue
fi
# Check if message matches conventional commit format
if ! echo "$message" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+'; then
echo "❌ Commit $sha does not follow Conventional Commits format:"
echo " $message"
echo ""
echo "Expected format: <type>[optional scope]: <description>"
echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo ""
echo "Examples:"
echo " feat: add new feature"
echo " fix: resolve bug in parser"
echo " feat(api): add new endpoint"
echo " feat!: breaking change"
exit 1
else
echo "✓ $sha: $message"
fi
done
echo ""
echo "✅ All commits follow Conventional Commits format"
downstream-imports:
name: Downstream import smoke (representative consumer symbols)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
cache-dependency-path: pyproject.toml
- name: Build sdist and install its wheel
run: |
python -m pip install --upgrade pip build
test ! -e src/adcp/_schemas
# The default build path creates the sdist first, then builds the
# wheel from that sdist. This covers both VCS and sdist consumers.
python -m build --outdir dist/
pip install dist/*.whl
- name: Verify VCS-built wheel schemas
working-directory: /tmp
run: |
python - <<'PY'
import json
from importlib.resources import files
from adcp import get_adcp_spec_version
from adcp.server.helpers import STANDARD_ERROR_CODES, adcp_error
from adcp.types import ReportingCapabilities
from adcp.types.versioned import make_versioned_base
spec_version = get_adcp_spec_version()
current_bundle = (
spec_version
if "-" in spec_version
else ".".join(spec_version.split(".")[:2])
)
schema_root = files("adcp") / "_schemas"
packaged_versions = {
child.name
for child in schema_root.iterdir()
if child.is_dir()
}
# Preserve explicit rc.3 and the frozen v32 (beta.6) schemas offline.
assert packaged_versions == {
"2.5", "3.0", "3.1", "3.2.0-beta.6", "3.2.0-rc.3", current_bundle
}
error_schema = json.loads(
(schema_root / current_bundle / "enums/error-code.json").read_text(encoding="utf-8")
)
# NOT_SUPPORTED is the SDK's explicit extension to the pinned vocabulary.
assert set(STANDARD_ERROR_CODES) == {*error_schema["enum"], "NOT_SUPPORTED"}
assert adcp_error("AUTH_MISSING", "x")["errors"][0]["recovery"] == "correctable"
assert make_versioned_base("3.1", "ListCreativesRequest")
assert ReportingCapabilities.__name__ == "ReportingCapabilities"
PY
# Proxy for real downstream import sites (salesagent, creative agents,
# signals agents). Any ImportError here means we broke the public API
# surface without a migration pointer — failing CI is the goal.
- name: Import representative public-API symbols
run: |
python - <<'PY'
from adcp import (
ADCPClient,
AgentConfig,
BrandReference,
CpmPricingOption,
CreateMediaBuyRequest,
Error,
GetProductsRequest,
ListCreativesRequest,
MediaBuyStatus,
Package,
PackageRequest,
PublisherPropertiesAll,
SyncCatalogsRequest,
)
from adcp.types import (
AudioFormatAsset,
BriefFormatAsset,
CatalogFormatAsset,
ContextObject,
CreativeAsset,
CssFormatAsset,
DaastFormatAsset,
HtmlFormatAsset,
ImageFormatAsset,
JavascriptFormatAsset,
MarkdownFormatAsset,
RepeatableAssetGroup,
TargetingOverlay,
TextFormatAsset,
UrlFormatAsset,
VastFormatAsset,
VideoFormatAsset,
WebhookFormatAsset,
)
# Removed-type shims: old import paths must raise a guided
# ImportError pointing at the migration guide.
import adcp
for name in ("BrandManifest", "FormatCategory", "DeliverTo"):
try:
getattr(adcp, name)
except ImportError as exc:
assert "MIGRATION_v3_to_v4" in str(exc), (
f"{name} deprecation shim dropped migration pointer: {exc}"
)
else:
raise AssertionError(
f"{name} import should raise ImportError with migration pointer"
)
# The deep submodule path (some older import sites reach this far)
# must also surface the migration pointer, not a bare ModuleNotFoundError.
try:
from adcp.types.generated_poc.enums.format_category import FormatCategory # noqa: F401
except ImportError as exc:
assert "MIGRATION_v3_to_v4" in str(exc), exc
else:
raise AssertionError(
"format_category submodule should raise ImportError with migration pointer"
)
assert adcp.__version__ and adcp.__version__ != "3.12.0", (
f"adcp.__version__={adcp.__version__!r} — expected real pkg metadata"
)
assert adcp.get_adcp_version(), "ADCP_VERSION file is empty"
print(f"OK — adcp=={adcp.__version__}, spec={adcp.get_adcp_version()}")
PY
schema-check:
name: Validate schemas are up-to-date
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Check ADCP version
id: version-check
run: |
VERSION=$(cat src/adcp/ADCP_VERSION)
echo "ADCP_VERSION=$VERSION"
# Skip regeneration + drift check for pre-release tags (alpha/beta/rc)
# and for `latest`, which is a moving dev snapshot — the committed
# generated types are frozen against the bundle we last synced, and
# CI's fresh sync against today's `latest.tgz` is expected to drift.
if echo "$VERSION" | grep -qE '(alpha|beta|rc)' || [ "$VERSION" = "latest" ]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Pre-release / latest version detected - will skip schema sync"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Stable version - will sync schemas from upstream"
fi
# Stable upstream tags ship Sigstore sidecars; sync_schemas.py
# verifies the bundle via `cosign verify-blob` before extraction.
# Installs cosign from the official sigstore/cosign-installer action.
- name: Install cosign (for signature verification)
if: steps.version-check.outputs.is_prerelease != 'true'
uses: sigstore/cosign-installer@v3
- name: Download latest schemas
if: steps.version-check.outputs.is_prerelease != 'true'
run: python scripts/sync_schemas.py
- name: Fix schema references
if: steps.version-check.outputs.is_prerelease != 'true'
run: python scripts/fix_schema_refs.py
- name: Bundle schemas into package
if: steps.version-check.outputs.is_prerelease != 'true'
run: python scripts/bundle_schemas.py
- name: Generate models
run: python scripts/generate_types.py
- name: Validate generated code syntax
run: |
echo "Validating generated code can be parsed..."
python -m py_compile src/adcp/types/_generated.py
echo "✓ Syntax validation passed"
- name: Validate generated code imports
run: |
echo "Validating generated code can be imported..."
python -c "from adcp.types import _generated as generated; print(f'✓ Successfully imported {len(dir(generated))} symbols')"
- name: Run code generation tests
run: |
echo "Running code generation test suite..."
pytest tests/test_code_generation.py -v --tb=short
- name: Check for schema drift
if: steps.version-check.outputs.is_prerelease != 'true'
run: |
# datamodel-codegen's numbered-variant class names
# (Pass1/Pass4, Status16/Status17, StatusFilter1/StatusFilter4,
# Type80, etc.) shift between regens because the generator
# walks the schema graph in filesystem-iteration order and
# APFS (macOS) vs. ext4 (Linux CI) sort differently. The
# numbers are an implementation detail; semantic aliases in
# ``src/adcp/types/aliases.py`` pin the names downstream
# actually uses.
#
# The real drift guarantees we need are enforced elsewhere:
# * ``tests/test_schemas_version_pin.py`` — ADCP_VERSION
# matches ``schemas/cache/index.json.adcp_version`` on
# every test run.
# * This job's "Validate generated code syntax/imports"
# steps above — the regenerated code compiles and imports.
# * ``tests/test_asset_aliases_stable.py`` — the semantic
# aliases still point at valid classes.
#
# We keep this step as a "regen runs without error on stable
# tags" smoke — but don't fail on line-level diff, because
# the non-determinism produces false positives that block
# release PRs for cosmetic churn.
if git diff --quiet src/adcp/types/_generated.py schemas/cache/; then
echo "✓ Schemas are up-to-date (no diff)"
else
echo "ℹ Regen produced cosmetic diff — see aliases.py for stable names"
echo " Numbered-variant class-name churn is expected; the semantic"
echo " alias tests and drift-version-pin test guard the real surface."
fi
storyboard:
name: AdCP storyboard runner — examples/seller_agent.py (@adcp/sdk ${{ matrix.adcp-sdk-tag }})
runs-on: ubuntu-latest
timeout-minutes: 20
# Blocking gate: examples/seller_agent.py is the Python-owned
# reference target for bidirectional storyboard interop. The matrix
# runs two legs: the sticky ``adcp-3.0`` tag is a fixed, reproducible
# backwards-compat floor (the AdCP 3.0 runner line), while ``latest``
# tracks the current stable @adcp/sdk release (the AdCP 3.1 line).
strategy:
fail-fast: false
matrix:
adcp-sdk-tag: ["adcp-3.0", "latest"]
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
# Cache the npm tarball + extracted package directory so the
# storyboard runner install isn't a cold network fetch every run.
# The cache key is salted by the npm dist-tag; npm still resolves
# the current concrete package for that tag on install.
- name: Cache ~/.npm
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-npm-adcp-sdk-${{ matrix.adcp-sdk-tag }}
restore-keys: |
${{ runner.os }}-npm-
- name: Run reference seller storyboard harness
timeout-minutes: 15
env:
PYTHON: python
ADCP_SDK_VERSION: ${{ matrix.adcp-sdk-tag }}
STORYBOARD_RESULT_PATH: storyboard-result-${{ matrix.adcp-sdk-tag }}.json
run: |
scripts/ci/run_storyboard_reference_seller.sh
- if: always()
uses: actions/upload-artifact@v7
with:
name: storyboard-result-${{ matrix.adcp-sdk-tag }}-${{ github.run_attempt }}
path: storyboard-result-${{ matrix.adcp-sdk-tag }}.json
if-no-files-found: warn
storyboard-required-gate:
name: AdCP storyboard runner — examples/seller_agent.py
runs-on: ubuntu-latest
timeout-minutes: 5
needs: storyboard
# Run after a failed matrix so the aggregate required check fails, but
# do not keep superseded workflow runs alive after concurrency cancels them.
if: ${{ !cancelled() }}
steps:
- name: Require all reference seller storyboard matrix runs
run: |
if [ "${{ needs.storyboard.result }}" != "success" ]; then
echo "Reference seller storyboard matrix result: ${{ needs.storyboard.result }}"
exit 1
fi
echo "Reference seller storyboard matrix passed"
v3-reference-seller-tests:
name: v3 reference seller — pytest (respx-mocked upstream)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,pg]"
# Example-local deps: the v3 reference seller imports
# sqlalchemy + asyncpg + httpx-respx but those aren't in the
# SDK's [dev] extras. Install them inline rather than adding
# a separate optional-dependencies group for a single example.
pip install "sqlalchemy[asyncio]>=2.0" "asyncpg>=0.29" "respx>=0.20"
- name: Run translator-pattern tests
# The tests respx-mock the JS mock-server upstream so we don't
# need to boot Node here. Storyboard CI (below) covers the
# real boot-the-upstream path.
run: |
pytest examples/v3_reference_seller/tests/ -v
storyboard-v3-reference-seller:
name: AdCP storyboard runner — v3 reference seller (translator)
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Intentionally non-secret: gates loopback-only debug counters
# inside this CI job.
ADCP_DEBUG_TOKEN: storyboard-debug-token
# Required as of @adcp/sdk@6.7.0 (sales-guaranteed mock-server
# canonicalized; closes #449). Storyboard run + traffic-counter
# assertions gate every PR's translator-pattern conformance.
services:
postgres:
image: postgres:16
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: adcp
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
# Same cache pattern as the storyboard job: keyed by
# ADCP_SDK_VERSION so a bump invalidates deterministically.
- name: Cache ~/.npm
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,pg]"
# Example-local deps: the v3 reference seller imports
# sqlalchemy + asyncpg (async Postgres driver) which aren't
# in the SDK's [dev,pg] extras. Install inline rather than
# adding an example-only optional-dependencies group.
pip install "sqlalchemy[asyncio]>=2.0" "asyncpg>=0.29" "respx>=0.20"
- name: Pre-install @adcp/sdk (once, then call binary directly)
# See the comment on the storyboard job's install step for the
# AAO reference-formats fixture rationale (upstream adcp#3307).
run: |
npm install -g @adcp/sdk@${ADCP_SDK_VERSION}
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"
- name: Start JS mock-server upstream
run: |
# Cached install above means this is a hot-start (~2-3s on
# GHA-hosted runners) — no npm install delay. Tee output to
# /tmp/mock-server.log so failures surface their stack traces
# (otherwise the readiness loop just times out silently).
adcp mock-server sales-guaranteed --port 4503 --api-key test-key \
> /tmp/mock-server.log 2>&1 &
MOCK_PID=$!
echo "MOCK_PID=$MOCK_PID" >> "$GITHUB_ENV"
# Health-check via /_debug/traffic — non-network-scoped and
# no-auth, so it doesn't break when the JS mock's seed-data
# renames or removes a specific network. The endpoint is
# always present on the harness-side mock.
for i in $(seq 1 120); do
# ``||`` runs on the assignment so curl's "000" stdout and the
# fallback don't concatenate when the connection is refused.
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
http://127.0.0.1:4503/_debug/traffic 2>/dev/null) || HTTP_CODE="000"
if [ "$HTTP_CODE" = "200" ]; then
echo "Upstream mock ready (HTTP 200, pid $MOCK_PID, $i polls)"
break
fi
if [ "$i" -eq 120 ]; then
echo "Upstream mock failed to start within 60s"
echo "--- mock-server.log ---"
cat /tmp/mock-server.log || echo "(log unavailable)"
kill "$MOCK_PID" 2>/dev/null || true
exit 1
fi
sleep 0.5
done
- name: Seed Postgres fixtures
env:
DATABASE_URL: postgresql+asyncpg://postgres@127.0.0.1:5432/adcp
run: |
cd examples/v3_reference_seller
python -m seed
- name: Boot v3 reference seller (translator)
env:
DATABASE_URL: postgresql+asyncpg://postgres@127.0.0.1:5432/adcp
MOCK_AD_SERVER_URL: http://127.0.0.1:4503
MOCK_AD_SERVER_API_KEY: test-key
PORT: "3001"
run: |
cd examples/v3_reference_seller
python -m src.app > /tmp/v3-reference-seller.log 2>&1 &
SELLER_PID=$!
echo "SELLER_PID=$SELLER_PID" >> "$GITHUB_ENV"
for i in $(seq 1 120); do
# ``||`` runs on the assignment so curl's "000" stdout and the
# fallback don't concatenate when the connection is refused.
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
http://127.0.0.1:3001/mcp 2>/dev/null) || HTTP_CODE="000"
if [ "$HTTP_CODE" != "000" ]; then
echo "Seller ready (HTTP ${HTTP_CODE}, pid ${SELLER_PID})"
break
fi
if [ "$i" -eq 120 ]; then
echo "Seller failed to start within 60s"
echo "--- v3-reference-seller.log ---"
cat /tmp/v3-reference-seller.log || echo "(log unavailable)"
kill "$SELLER_PID" 2>/dev/null || true
exit 1
fi
sleep 0.5
done
# Upstream-still-alive probe — guard against the upstream
# dying during seller startup (e.g. seller's connection
# handshake crashes the mock). If the upstream is gone,
# the storyboard run will fail in confusing ways; fail
# here with a clear diagnostic instead.
UPSTREAM_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
-H "Authorization: Bearer test-key" \
-H "X-Network-Code: net_premium_us" \
http://127.0.0.1:4503/v1/products 2>/dev/null || echo "000")
if [ "$UPSTREAM_CODE" != "200" ]; then
echo "Upstream mock no longer responding after seller boot (HTTP ${UPSTREAM_CODE})"
echo "The seller likely crashed the upstream during connection handshake."
kill "$SELLER_PID" 2>/dev/null || true
kill "$MOCK_PID" 2>/dev/null || true
exit 1
fi
echo "Upstream still alive after seller boot (HTTP ${UPSTREAM_CODE})"
- name: Run storyboard suite
timeout-minutes: 5
run: |
# /etc/hosts override so the buyer can reach acme.localhost
# (the seeded tenant subdomain).
echo "127.0.0.1 acme.localhost" | sudo tee -a /etc/hosts
cd examples/v3_reference_seller
# ``adcp`` was installed once at job start — call the binary
# directly to skip per-invocation ``npx`` extract+link.
# --auth carries the seeded bearer credential (seed.py:74) so the
# storyboard runner's requests resolve through the v3 ref seller's
# PgBuyerAgentRegistry to ba_acme_bearer. Without this, every
# request hits the Tier 2 commercial-allowlist gate and storyboard
# steps fail with PERMISSION_DENIED across the board.
adcp storyboard run \
http://acme.localhost:3001/mcp media_buy_seller \
--auth dev-bearer-token-acme-1 \
--test-kit ../../tests/fixtures/storyboard-test-kit.yaml \
--json --allow-http \
> v3-storyboard-result.json
- name: Assert storyboard runner produced output
run: |
# Hard gate: the runner must produce a non-empty result file.
# The anti-façade invariant (the seller actually called upstream)
# is enforced by "Assert upstream traffic" below — controller_detected
# is observational; it has never been true for the v3 reference
# seller's translator topology (the runner detects it for the
# examples/seller_agent.py stub-mode topology only).
python -c "
import json, sys, pathlib
p = pathlib.Path('examples/v3_reference_seller/v3-storyboard-result.json')
if not p.exists() or p.stat().st_size == 0:
print('v3-storyboard-result.json missing or empty — runner produced no output')
sys.exit(1)
with p.open() as f:
d = json.load(f)
print('overall_status:', d.get('overall_status'))
print('controller_detected:', d.get('controller_detected'))
print('summary:', json.dumps(d.get('summary', {}), indent=2))
"
- name: Assert storyboard passed
# Hard gate. This translator intentionally has no
# ``comply_test_controller``: it exercises a real upstream rather
# than owning the runner's state-control API. SDK 13 therefore grades
# its coverage ``partial`` even when every executable step passes.
# Accept only that exact topology — zero failed steps/tracks, useful
# executed coverage, and no skip class outside controller absence,
# its dependent steps, missing controller tools, or not-applicable
# capabilities. Any real failure or new skip class still blocks CI.
run: |
python -c "
import json, sys, pathlib
p = pathlib.Path('examples/v3_reference_seller/v3-storyboard-result.json')
with p.open() as f:
d = json.load(f)
status = d.get('overall_status')
summary = d.get('summary') or {}
skip_counts = summary.get('skipped_by_reason') or {}
allowed_partial_skips = {
'missing_test_controller',
'missing_tool',
'prerequisite_failed',
'not_applicable',
}
unexpected_skips = sorted(set(skip_counts) - allowed_partial_skips)
steps = [
step
for track in d.get('tracks', [])
for scenario in track.get('scenarios', [])
for step in scenario.get('steps', [])
]
unexpected_missing_tools = [
step.get('task')
for step in steps
if step.get('skip_reason') == 'missing_tool'
and step.get('task') != 'comply_test_controller'
]
unexpected_prerequisites = [
step.get('error')
for step in steps
if step.get('skip_reason') == 'prerequisite_failed'
and 'skipped (missing_tool)' not in (step.get('error') or '')
]
partial_is_expected = (
status == 'partial'
and summary.get('steps_failed') == 0
and summary.get('tracks_failed') == 0
and (summary.get('steps_passed') or 0) > 0
and (skip_counts.get('missing_test_controller') or 0) > 0
and not unexpected_skips
and not unexpected_missing_tools
and not unexpected_prerequisites
and d.get('controller_detected') is False
)
if status != 'passing' and not partial_is_expected:
print(json.dumps(d, indent=2))
if unexpected_skips:
print(f'Unexpected partial skip classes: {unexpected_skips}')
if unexpected_missing_tools:
print(f'Unexpected missing tools: {unexpected_missing_tools}')
if unexpected_prerequisites:
print(f'Unexpected prerequisite skips: {unexpected_prerequisites}')
sys.exit(1)
print(f'Storyboard accepted: status={status}, summary={summary}')
"
- name: Assert upstream traffic (anti-façade gate)
run: |
# The v3 reference seller exposes /_debug/traffic with per-method
# upstream-call counts. If the storyboard passed but the counts
# are empty/zero, the seller served stub data without translating
# to upstream — the façade-mode failure this job exists to catch
# (see #410).
curl -sf -H "X-Debug-Token: ${ADCP_DEBUG_TOKEN}" \
http://127.0.0.1:3001/_debug/traffic > traffic.json
python -c "
import json, sys
with open('traffic.json') as f:
counts = json.load(f)
if not counts:
print('Empty traffic snapshot — seller invoked no upstream methods.')
print('This is the façade-mode failure #410 gates against.')
sys.exit(1)
total = sum(counts.values())
if total == 0:
print(f'All upstream-method counts zero: {counts!r}')
print('Seller served stub data without calling upstream — façade failure.')
sys.exit(1)
print(f'Upstream traffic: total={total} per-method={counts!r}')
"
- if: always()
uses: actions/upload-artifact@v7
with:
name: v3-storyboard-result-${{ github.run_attempt }}
path: examples/v3_reference_seller/v3-storyboard-result.json
if-no-files-found: warn
storyboard-multi-platform-seller:
name: AdCP storyboard runner — examples/multi_platform_seller (PlatformRouter)
runs-on: ubuntu-latest
timeout-minutes: 20
# Multi-tenant proof: one process, two tenants, one router. Each
# tenant's storyboard runs against its own subdomain
# (tenant-a.localhost / tenant-b.localhost). Blocking gate — both
# tenants must pass the AdCP storyboard suite for the example to
# remain a credible reference. continue-on-error was dropped after
# the mocks were aligned with the wire contract; future drift now
# blocks CI.
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Cache ~/.npm
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }}
restore-keys: |
${{ runner.os }}-npm-
- name: Pre-install @adcp/sdk
# See the comment on the storyboard job's install step for the
# AAO reference-formats fixture rationale (upstream adcp#3307).
run: |
npm install -g @adcp/sdk@${ADCP_SDK_VERSION}
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Map tenant subdomains to localhost
run: |
# The storyboard runner connects to ``tenant-x.localhost`` —
# /etc/hosts gives those names a 127.0.0.1 mapping so the
# subdomain middleware on the seller process resolves the
# right tenant from the Host header.
echo "127.0.0.1 tenant-a.localhost tenant-b.localhost" \
| sudo tee -a /etc/hosts
- name: Boot multi-platform seller
run: |
ADCP_PORT=3001 python -m examples.multi_platform_seller.src.app &
SELLER_PID=$!
echo "SELLER_PID=$SELLER_PID" >> "$GITHUB_ENV"
for i in $(seq 1 60); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
http://127.0.0.1:3001/mcp 2>/dev/null) || HTTP_CODE="000"
if [ "$HTTP_CODE" != "000" ]; then
echo "Seller ready (HTTP ${HTTP_CODE}, pid ${SELLER_PID})"
break
fi
if ! kill -0 "$SELLER_PID" 2>/dev/null; then
echo "Seller process died during startup"
exit 1
fi
if [ "$i" -eq 60 ]; then
echo "Seller failed to start within 30s"
kill "$SELLER_PID" 2>/dev/null || true
exit 1
fi
sleep 0.5
done
- name: Run storyboard — tenant-a (sales-guaranteed)
timeout-minutes: 5
run: |
adcp storyboard run \
http://tenant-a.localhost:3001/mcp media_buy_seller \
--json --allow-http \
> tenant-a-storyboard.json
cat tenant-a-storyboard.json | head -50
- name: Run storyboard — tenant-b (sales-non-guaranteed)
timeout-minutes: 5
run: |
adcp storyboard run \
http://tenant-b.localhost:3001/mcp media_buy_seller \
--json --allow-http \
> tenant-b-storyboard.json
cat tenant-b-storyboard.json | head -50
- if: always()
uses: actions/upload-artifact@v7
with:
name: multi-platform-storyboards-${{ github.run_attempt }}
path: |
tenant-a-storyboard.json
tenant-b-storyboard.json
if-no-files-found: warn
storyboard-sales-proposal-mode:
name: AdCP storyboard runner — sales-proposal-mode (proposal_finalize)
runs-on: ubuntu-latest
timeout-minutes: 20
# v1.5 ProposalManager finalize lifecycle proof. The mock seller
# declares ``finalize=True`` + wires an ``InMemoryProposalStore``;
# the framework's dispatch wiring intercepts ``refine[i].action='finalize'``
# requests, runs ``finalize_proposal``, commits via the store, and
# auto-hydrates ``ctx.recipes`` on subsequent ``create_media_buy``
# calls. This job is the storyboard-level proof that the design
# works end-to-end. Blocking gate — no continue-on-error.
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Cache ~/.npm
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-npm-adcp-sdk-${{ env.ADCP_SDK_VERSION }}
restore-keys: |
${{ runner.os }}-npm-
- name: Pre-install @adcp/sdk
# See the comment on the storyboard job's install step for the
# AAO reference-formats fixture rationale (upstream adcp#3307).
run: |
npm install -g @adcp/sdk@${ADCP_SDK_VERSION}
adcp --version
SDK_ROOT="$(npm root -g)/@adcp/sdk"
mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures"
cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json"
mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries"
cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Boot sales-proposal-mode seller
run: |
ADCP_PORT=3003 python -m examples.sales_proposal_mode_seller.src.app &
SELLER_PID=$!
echo "SELLER_PID=$SELLER_PID" >> "$GITHUB_ENV"
for i in $(seq 1 60); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
http://127.0.0.1:3003/mcp 2>/dev/null) || HTTP_CODE="000"
if [ "$HTTP_CODE" != "000" ]; then
echo "Seller ready (HTTP ${HTTP_CODE}, pid ${SELLER_PID})"
break
fi
if ! kill -0 "$SELLER_PID" 2>/dev/null; then
echo "Seller process died during startup"
exit 1
fi
if [ "$i" -eq 60 ]; then
echo "Seller failed to start within 30s"
kill "$SELLER_PID" 2>/dev/null || true
exit 1
fi
sleep 0.5
done
- name: Run storyboard — proposal_finalize
timeout-minutes: 5
# Full proposal_finalize storyboard. With @adcp/sdk@6.10.0 the
# runner's stateful-chain controller exercises every phase
# (setup, brief, refine, finalize, accept) end-to-end against
# the framework's intercept seam. Earlier SDK versions skipped
# refine/finalize/accept due to a sync_accounts cascade gap
# that 6.10.0 closed (adcp#4053, adcp-client#1146/#1545).
run: |
adcp storyboard run \
http://127.0.0.1:3003/mcp media_buy_seller/proposal_finalize \
--json --allow-http \
> proposal-finalize-storyboard.json
cat proposal-finalize-storyboard.json | head -200
- name: Assert v1.5 dispatch path scenarios pass
run: |
python -c "
import json, sys, pathlib
p = pathlib.Path('proposal-finalize-storyboard.json')
if not p.exists() or p.stat().st_size == 0:
print('storyboard result missing or empty')
sys.exit(1)
with p.open() as f:
d = json.load(f)
# Every phase of the proposal_finalize storyboard must pass:
# setup → brief_with_proposals → refine_proposal →
# finalize_proposal → accept_proposal. Each exercises a
# different framework seam — see the per-scenario hints
# below for what's wired where.
required_passing = {
'media_buy_seller/proposal_finalize/setup',
'media_buy_seller/proposal_finalize/brief_with_proposals',
'media_buy_seller/proposal_finalize/refine_proposal',
'media_buy_seller/proposal_finalize/finalize_proposal',
'media_buy_seller/proposal_finalize/accept_proposal',
}
passed = set()
for track in d.get('tracks', []) or []:
for s in track.get('scenarios', []) or []:
if s.get('overall_passed'):
passed.add(s.get('scenario'))
missing = required_passing - passed
if missing:
# Per-scenario likely-cause hints. A contributor breaks the
# framework finalize wiring; CI tells them what to look at
# rather than just naming a scenario.
hints = {
'media_buy_seller/proposal_finalize/setup': (
'sync_accounts dispatch failed (or the runner stopped '
'applying the sole-stateful-step exemption). See '
'docs/proposals/proposal-manager-v15-design.md § D5.'
),
'media_buy_seller/proposal_finalize/brief_with_proposals': (
'Manager.get_products + framework draft persistence '
'broke. Check maybe_persist_draft_after_get_products '
'in src/adcp/decisioning/proposal_dispatch.py. See '
'docs/proposals/proposal-manager-v15-design.md § D1.'
),
'media_buy_seller/proposal_finalize/refine_proposal': (
'Refine iteration broke. Check refine_products on '
'examples/sales_proposal_mode_seller/src/proposal_manager.py '
'and the maybe_persist_draft_after_get_products '
'overwrite path. See § D1 (refine iteration).'
),
'media_buy_seller/proposal_finalize/finalize_proposal': (
'Finalize interception broke. Check '
'maybe_intercept_finalize in proposal_dispatch.py + '
'manager.finalize_proposal in the example. The seam '
'must commit via store.commit before projecting. See § D2.'
),
'media_buy_seller/proposal_finalize/accept_proposal': (
'create_media_buy(proposal_id=...) or '
'create_media_buy(packages=[...]) broke. Check '
'maybe_hydrate_recipes_for_create_media_buy + '
'mark_proposal_consumed in proposal_dispatch.py and '
'the platform.create_media_buy adapter. See § D3 + D7.'
),
}
print('FAIL: required scenarios did not pass:')
for s in sorted(missing):
hint = hints.get(s, 'no hint registered for this scenario')
print(f' - {s}')
print(f' likely cause: {hint}')
print()
print('--- raw storyboard result ---')
print(json.dumps(d, indent=2))
sys.exit(1)
print('PASS: full proposal_finalize storyboard chain (5/5 phases)')
"
- if: always()
uses: actions/upload-artifact@v7
with:
name: sales-proposal-mode-storyboard-${{ github.run_attempt }}
path: proposal-finalize-storyboard.json
if-no-files-found: warn