From 0c01849b7fc6eba9aee499dd2df8d3fa4c76ab13 Mon Sep 17 00:00:00 2001 From: codex-maintenance <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:30:32 +0800 Subject: [PATCH 1/2] fix: publish account facts independently on existing schedule Co-Authored-By: Codex --- .../workflows/execution-report-heartbeat.yml | 58 ++++- scripts/README_account_facts_projection.md | 6 +- scripts/publish_account_facts_from_report.py | 86 +++++--- tests/test_publish_ibkr_account_facts.py | 202 ++++++++++++------ 4 files changed, 247 insertions(+), 105 deletions(-) diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index 5593d63..b639234 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -28,7 +28,7 @@ on: default: disabled options: - disabled - - live-u16608560 + - primary-live - ingress-diagnostic schedule: - cron: "20 22 * * *" @@ -122,20 +122,56 @@ jobs: env: EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} - - name: Publish one fresh IBKR account-facts report - if: ${{ github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'live-u16608560' }} - env: - IBKR_ACCOUNT_FACTS_TARGET: ${{ inputs.account_facts_target }} - IBKR_ACCOUNT_FACTS_REPORT_PREFIX: gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560 - IBKR_ACCOUNT_FACTS_RUNTIME_REVISION: ${{ vars.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION }} - IBKR_ACCOUNT_FACTS_SYNC_URL: ${{ vars.IBKR_ACCOUNT_FACTS_SYNC_URL }} - IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }} - run: uv run --no-sync python scripts/publish_account_facts_from_report.py - - name: Send configured daily dry-run digest if: ${{ always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest) }} run: uv run --no-sync python scripts/daily_dry_run_digest.py + account-facts-publisher: + name: Publish latest fresh IBKR account-facts report + if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'primary-live') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + env: + GCP_PROJECT_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + IBKR_ACCOUNT_FACTS_TARGET: primary-live + IBKR_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.IBKR_ACCOUNT_FACTS_REPORT_PREFIX }} + IBKR_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_TARGET_ID }} + IBKR_ACCOUNT_FACTS_SERVICE_NAME: ${{ secrets.IBKR_ACCOUNT_FACTS_SERVICE_NAME }} + IBKR_ACCOUNT_FACTS_RUNTIME_REVISION: ${{ secrets.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION }} + IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE }} + IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON }} + IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR: ${{ secrets.IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR }} + IBKR_ACCOUNT_FACTS_SYNC_URL: ${{ vars.IBKR_ACCOUNT_FACTS_SYNC_URL }} + IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - name: Install dependencies + run: | + set -euo pipefail + uv sync --frozen --no-dev + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v3 + + - name: Publish one validated report + run: uv run --no-sync python scripts/publish_account_facts_from_report.py + account-facts-ingress-diagnostic: name: Verify account-facts ingress authentication only if: ${{ github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'ingress-diagnostic' }} diff --git a/scripts/README_account_facts_projection.md b/scripts/README_account_facts_projection.md index 638ec51..923a9f6 100644 --- a/scripts/README_account_facts_projection.md +++ b/scripts/README_account_facts_projection.md @@ -8,6 +8,8 @@ The projected record uses `schema_version: "ibkr_account_snapshot_history.v1"` a `source_binding.id` is SHA-256 over sorted, compact canonical JSON containing only `project_id`, `service_name`, `runtime_revision`, `account_scope`, the original one-element `account_selector`, and `deployment_selector`. It excludes amounts, report URI, and token versions. The report URI is used only for GCS prefix validation and is never added to the history body. Native account IDs must match `U` or `DU` followed by digits. -The same script also has a workflow-only publisher entry point. It is disabled unless the manual workflow input explicitly selects `live-u16608560`. That path lists only the exact TQQQ/U16608560 prefix `gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560`, selects the newest timestamp-named report, reads that one object, and requires `finished_at` within the last 15 minutes or at most 5 minutes in the future. The expected Cloud Run revision comes from `vars.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION`; it is never learned from the report being checked. An unset revision, missing account facts, stale report, or metadata mismatch stops before publishing. +The workflow publisher is disabled by default. A manual run must select the generic `primary-live` action; the existing daily heartbeat schedule invokes the same publisher in a separate job so its outcome is visible even if the heartbeat check fails. It lists only the report prefix supplied by protected configuration, selects the newest timestamp-named report across the current and preceding month, and never falls back to an older report if the newest report is stale or lacks account facts. Freshness is based on the actual aware account observation within the existing 36-hour display window (and at most five minutes in the future); the timestamp is not rewritten. The publisher does not run a broker query, Cloud Scheduler job, or Cloud Run request. -Publishing requires `vars.IBKR_ACCOUNT_FACTS_SYNC_URL` to exactly equal `https://qsl-strategy-switch-console.pigbibi.workers.dev/api/account-facts/sync` and the dedicated `secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN`. It makes one HTTPS POST attempt without redirects or retries, and prints fixed status/reason codes only. Scheduled heartbeat runs never call this path. This publisher does not invoke a broker, scheduler, or new runtime report, and does not use the legacy execution-evidence token. +All private target mapping is supplied by protected Secrets rather than public workflow values: `IBKR_ACCOUNT_FACTS_REPORT_PREFIX`, `IBKR_ACCOUNT_FACTS_TARGET_ID`, `IBKR_ACCOUNT_FACTS_PROJECT_ID`, `IBKR_ACCOUNT_FACTS_SERVICE_NAME`, `IBKR_ACCOUNT_FACTS_RUNTIME_REVISION`, `IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE`, `IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON`, and `IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR`. `IBKR_ACCOUNT_FACTS_SYNC_TOKEN` remains the dedicated publisher credential; `IBKR_ACCOUNT_FACTS_SYNC_URL` remains an exact-match protected variable. Missing or malformed mapping, an absent account-facts observation, stale observation, or any metadata mismatch stops without POST. + +Publishing requires the exact configured sync endpoint and dedicated `secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN`. It makes one HTTPS POST attempt without redirects or retries, and prints fixed status/reason codes only. The publisher reports only the transport/storage result; daily automatic refresh is accepted only after a later natural runtime report carries a newer original `account_facts.observed_at`. Replaying an unchanged report is not evidence of an automatic refresh. This publisher does not use the legacy execution-evidence token. diff --git a/scripts/publish_account_facts_from_report.py b/scripts/publish_account_facts_from_report.py index c7c4f52..3d434d8 100644 --- a/scripts/publish_account_facts_from_report.py +++ b/scripts/publish_account_facts_from_report.py @@ -30,6 +30,7 @@ IBKR_ACCOUNT_FACTS_USER_AGENT = "QSL-IBKR-AccountFacts/1.0" IBKR_ACCOUNT_FACTS_MAX_AGE = timedelta(hours=36) IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET = "ingress-diagnostic" +IBKR_ACCOUNT_FACTS_PRIMARY_TARGET = "primary-live" _INGRESS_DIAGNOSTIC_BODY = b"{}" _INGRESS_DIAGNOSTIC_ERROR = "invalid_account_facts_history" _REPORT_RUN_ID = re.compile(r"^\d{8}T\d{6}Z\.json$") @@ -99,6 +100,24 @@ def _exact_text(value: object) -> str: return value if isinstance(value, str) and value and value == value.strip() else "" +def _required_private_setting(name: str) -> str: + value = _exact_text(os.environ.get(name)) + if not value: + raise _ProjectionError("target_config_unavailable") + return value + + +def _expected_account_selector() -> tuple[str, ...]: + raw = _required_private_setting("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON") + try: + selector = _selector(json.loads(raw)) + except (json.JSONDecodeError, TypeError): + raise _ProjectionError("target_config_invalid") from None + if len(selector) != 1 or selector[0].lower() == "default": + raise _ProjectionError("target_config_invalid") + return selector + + def _observed_timestamp(value: object) -> datetime: if not isinstance(value, str) or not value.strip(): raise _ProjectionError("observation_invalid") @@ -303,6 +322,34 @@ def publish_ibkr_account_facts_history( http_status=_numeric_http_status(response.status), qrs_error_code="unknown", ) + response_body = response.read(_HTTP_ERROR_BODY_LIMIT + 1) + if not isinstance(response_body, bytes) or len(response_body) > _HTTP_ERROR_BODY_LIMIT: + return _publish_failed( + stage="http_response", + category="response_invalid", + http_status=_numeric_http_status(response.status), + ) + try: + response_payload = json.loads(response_body) + except (json.JSONDecodeError, TypeError): + return _publish_failed( + stage="http_response", + category="response_invalid", + http_status=_numeric_http_status(response.status), + ) + if ( + not isinstance(response_payload, Mapping) + or response_payload.get("ok") is not True + or response_payload.get("stored") is not True + or not isinstance(response_payload.get("unchanged"), bool) + ): + return _publish_failed( + stage="http_response", + category="response_invalid", + http_status=_numeric_http_status(response.status), + ) + if response_payload["unchanged"]: + return {"status": "unchanged", "reason": "observation_unchanged"} except HTTPError as exc: return _publish_failed( stage="http_response", @@ -461,7 +508,7 @@ def diagnose_account_facts_ingress(*, sync_token: str) -> dict[str, Any]: def _format_cli_result(result: Mapping[str, Any]) -> str: - status = result.get("status") if result.get("status") in {"published", "verified", "skipped"} else "skipped" + status = result.get("status") if result.get("status") in {"published", "unchanged", "verified", "skipped"} else "skipped" reason = result.get("reason") if isinstance(result.get("reason"), str) else "unknown" diagnostic = result.get("diagnostics") if not isinstance(diagnostic, Mapping): @@ -474,7 +521,7 @@ def _format_cli_result(result: Mapping[str, Any]) -> str: category = ( diagnostic.get("category") if diagnostic.get("category") in { - "http_status", "http_error", "timeout", "url_error", "transport_error", "unknown" + "http_status", "http_error", "response_invalid", "timeout", "url_error", "transport_error", "unknown" } else "unknown" ) @@ -557,33 +604,24 @@ def main() -> int: ) print(_format_cli_result(result)) return 0 if result.get("status") == "verified" else 1 - if target != "live-u16608560": + if target != IBKR_ACCOUNT_FACTS_PRIMARY_TARGET: print("skipped:target_disabled") return 0 - prefix = _text(os.environ.get("IBKR_ACCOUNT_FACTS_REPORT_PREFIX")) - expected_prefix = ( - "gs://qsl-runtime-logs-shared/execution-reports/" - "interactive_brokers/tqqq_growth_income/live-u16608560" - ) - if prefix != expected_prefix: - print("skipped:report_prefix_mismatch") - return 1 + prefix = _required_private_setting("IBKR_ACCOUNT_FACTS_REPORT_PREFIX") + expected_prefix = prefix expected = { - "target_id": "ibkr-u16608560", + "target_id": _required_private_setting("IBKR_ACCOUNT_FACTS_TARGET_ID"), "expected_report_prefix": expected_prefix, - "expected_project_id": "interactivebrokersquant", - "expected_service_name": "interactive-brokers-quant-live-u16608560-service", - "expected_runtime_revision": _text(os.environ.get("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION")), - "expected_account_scope": "live-u16608560", - "expected_account_selector": ["U16608560"], - "expected_deployment_selector": "live-u16608560", + "expected_project_id": _required_private_setting("IBKR_ACCOUNT_FACTS_PROJECT_ID"), + "expected_service_name": _required_private_setting("IBKR_ACCOUNT_FACTS_SERVICE_NAME"), + "expected_runtime_revision": _required_private_setting("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION"), + "expected_account_scope": _required_private_setting("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE"), + "expected_account_selector": _expected_account_selector(), + "expected_deployment_selector": _required_private_setting("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR"), } - if not expected["expected_runtime_revision"]: - print("skipped:expected_revision_unavailable") - return 1 now = datetime.now(timezone.utc) - uri = _latest_report_uri(prefix=expected_prefix, project_id="interactivebrokersquant", now=now) - report = _load_gcs_report(uri, project_id="interactivebrokersquant") + uri = _latest_report_uri(prefix=expected_prefix, project_id=expected["expected_project_id"], now=now) + report = _load_gcs_report(uri, project_id=expected["expected_project_id"]) result = publish_ibkr_account_facts_history( report, now=now, @@ -593,7 +631,7 @@ def main() -> int: **expected, ) print(_format_cli_result(result)) - return 0 if result["status"] == "published" else 1 + return 0 if result["status"] in {"published", "unchanged"} else 1 except _ProjectionError as exc: print(f"skipped:{exc.reason}") return 1 diff --git a/tests/test_publish_ibkr_account_facts.py b/tests/test_publish_ibkr_account_facts.py index 606897c..0d1e003 100644 --- a/tests/test_publish_ibkr_account_facts.py +++ b/tests/test_publish_ibkr_account_facts.py @@ -22,21 +22,21 @@ def _report() -> dict[str, object]: "schema_version": "runtime_report.v1", "platform": "interactive_brokers", "deploy_target": "cloud_run", - "project_id": "qsl-prod", - "service_name": "interactive-brokers-quant-live-u16608560-service", - "account_scope": "live-u16608560", + "project_id": "example-project", + "service_name": "ibkr-primary-service", + "account_scope": "live-primary", "runtime_target": { - "account_selector": ["U16608560"], - "deployment_selector": "live-u16608560", + "account_selector": ["U00000001"], + "deployment_selector": "live-primary", }, - "diagnostics": {"runtime_revision": "service-00369-88c"}, + "diagnostics": {"runtime_revision": "runtime-revision-001"}, "runtime_release_receipt": {"attestation_state": "legacy_unattested"}, "started_at": started_at.isoformat().replace("+00:00", "Z"), "finished_at": finished_at.isoformat().replace("+00:00", "Z"), "summary": { "account_facts": { "schema_version": "ibkr_account_snapshot.v1", - "account_ids": ["U16608560"], + "account_ids": ["U00000001"], "currency": "USD", "observed_at": observed_at.isoformat().replace("+00:00", "Z"), "net_assets": "12345.6700", @@ -52,15 +52,15 @@ def _report() -> dict[str, object]: def _project(report: dict[str, object]) -> dict[str, object]: return project_ibkr_account_facts_history( report, - target_id="ibkr-u16608560", + target_id="ibkr-primary", expected_report_prefix="gs://qsl-runtime-reports/ibkr", source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", - expected_project_id="qsl-prod", - expected_service_name="interactive-brokers-quant-live-u16608560-service", - expected_runtime_revision="service-00369-88c", - expected_account_scope="live-u16608560", - expected_account_selector=["U16608560"], - expected_deployment_selector="live-u16608560", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", + expected_account_selector=["U00000001"], + expected_deployment_selector="live-primary", ) @@ -70,7 +70,7 @@ def test_projects_bound_ibkr_facts_and_keeps_legacy_receipt_unchanged(): assert history["schema_version"] == "ibkr_account_snapshot_history.v1" assert history["snapshot_schema_version"] == "ibkr_account_snapshot.v1" - assert history["account_ids"] == ["U16608560"] + assert history["account_ids"] == ["U00000001"] assert history["broker_reported_balances"] == [ {"currency": "USD", "net_assets": "12345.6700"} ] @@ -117,15 +117,15 @@ def test_projection_fails_closed_on_scope_identity_and_selector_mismatch(): default_selector["runtime_target"]["account_selector"] = ["default"] # type: ignore[index] result = project_ibkr_account_facts_history( default_selector, - target_id="ibkr-u16608560", + target_id="ibkr-primary", expected_report_prefix="gs://qsl-runtime-reports/ibkr", source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", - expected_project_id="qsl-prod", - expected_service_name="interactive-brokers-quant-live-u16608560-service", - expected_runtime_revision="service-00369-88c", - expected_account_scope="live-u16608560", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", expected_account_selector=["default"], - expected_deployment_selector="live-u16608560", + expected_deployment_selector="live-primary", ) assert result == {"status": "skipped", "reason": "expected_target_invalid"} @@ -137,15 +137,15 @@ def test_projection_rejects_invalid_observation_provenance_and_duplicate_cash(): out_of_prefix = project_ibkr_account_facts_history( _report(), - target_id="ibkr-u16608560", + target_id="ibkr-primary", expected_report_prefix="gs://qsl-runtime-reports/ibkr", source_report_uri="gs://other-bucket/ibkr/report-1.json", - expected_project_id="qsl-prod", - expected_service_name="interactive-brokers-quant-live-u16608560-service", - expected_runtime_revision="service-00369-88c", - expected_account_scope="live-u16608560", - expected_account_selector=["U16608560"], - expected_deployment_selector="live-u16608560", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", + expected_account_selector=["U00000001"], + expected_deployment_selector="live-primary", ) assert out_of_prefix == {"status": "skipped", "reason": "report_provenance_invalid"} @@ -187,7 +187,7 @@ def test_projection_rejects_snapshot_observation_outside_report_interval(): assert _project(report) == {"status": "skipped", "reason": "observation_invalid"} padded_selector = _report() - padded_selector["runtime_target"]["account_selector"] = [" U16608560"] # type: ignore[index] + padded_selector["runtime_target"]["account_selector"] = [" U00000001"] # type: ignore[index] assert _project(padded_selector) == {"status": "skipped", "reason": "runtime_target_mismatch"} @@ -203,6 +203,9 @@ def __enter__(self): def __exit__(self, *_args): return None + def read(self, _limit): + return b'{"ok":true,"stored":true,"unchanged":false}' + class Opener: def open(self, request, *, timeout): observed["request"] = request @@ -216,21 +219,21 @@ def open(self, request, *, timeout): source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, sync_token="dedicated-test-token", - target_id="ibkr-u16608560", + target_id="ibkr-primary", expected_report_prefix="gs://qsl-runtime-reports/ibkr", - expected_project_id="qsl-prod", - expected_service_name="interactive-brokers-quant-live-u16608560-service", - expected_runtime_revision="service-00369-88c", - expected_account_scope="live-u16608560", - expected_account_selector=["U16608560"], - expected_deployment_selector="live-u16608560", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", + expected_account_selector=["U00000001"], + expected_deployment_selector="live-primary", ) assert result == {"status": "published"} request = observed["request"] assert request.get_method() == "POST" assert request.get_header("Authorization") == "Bearer dedicated-test-token" assert request.get_header("User-agent") == publisher.IBKR_ACCOUNT_FACTS_USER_AGENT - assert json.loads(request.data)["account_ids"] == ["U16608560"] + assert json.loads(request.data)["account_ids"] == ["U00000001"] assert observed["timeout"] == 15 @@ -246,6 +249,9 @@ def __enter__(self): def __exit__(self, *_args): return None + def read(self, _limit): + return b'{"ok":true,"stored":true,"unchanged":false}' + class Opener: def open(self, request, *, timeout): observed_requests.append((request, timeout)) @@ -256,14 +262,14 @@ def open(self, request, *, timeout): "source_report_uri": "gs://qsl-runtime-reports/ibkr/report-1.json", "sync_url": publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, "sync_token": "dedicated-test-token", - "target_id": "ibkr-u16608560", + "target_id": "ibkr-primary", "expected_report_prefix": "gs://qsl-runtime-reports/ibkr", - "expected_project_id": "qsl-prod", - "expected_service_name": "interactive-brokers-quant-live-u16608560-service", - "expected_runtime_revision": "service-00369-88c", - "expected_account_scope": "live-u16608560", - "expected_account_selector": ["U16608560"], - "expected_deployment_selector": "live-u16608560", + "expected_project_id": "example-project", + "expected_service_name": "ibkr-primary-service", + "expected_runtime_revision": "runtime-revision-001", + "expected_account_scope": "live-primary", + "expected_account_selector": ["U00000001"], + "expected_deployment_selector": "live-primary", } boundary = publisher.publish_ibkr_account_facts_history( _report(), now=datetime(2026, 10, 1, 13, 0, 1, tzinfo=timezone.utc), **args @@ -303,6 +309,46 @@ def open(self, request, *, timeout): assert wrong_endpoint == {"status": "skipped", "reason": "publish_target_invalid"} +def test_publisher_reports_unchanged_observation_without_claiming_refresh(monkeypatch): + observed = {"calls": 0} + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _limit): + return b'{"ok":true,"stored":true,"unchanged":true}' + + class Opener: + def open(self, *_args, **_kwargs): + observed["calls"] += 1 + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) + result = publisher.publish_ibkr_account_facts_history( + _report(), + now=datetime(2026, 9, 30, 1, 1, 30, tzinfo=timezone.utc), + source_report_uri="gs://example-private/ibkr/report-1.json", + sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, + sync_token="dedicated-test-token", + target_id="ibkr-primary", + expected_report_prefix="gs://example-private/ibkr", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", + expected_account_selector=["U00000001"], + expected_deployment_selector="live-primary", + ) + assert result == {"status": "unchanged", "reason": "observation_unchanged"} + assert observed["calls"] == 1 + + @pytest.mark.parametrize( ("status", "body_error", "expected_error"), [ @@ -335,14 +381,14 @@ def open(self, request, *, timeout): source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, sync_token="dedicated-test-token", - target_id="ibkr-u16608560", + target_id="ibkr-primary", expected_report_prefix="gs://qsl-runtime-reports/ibkr", - expected_project_id="qsl-prod", - expected_service_name="interactive-brokers-quant-live-u16608560-service", - expected_runtime_revision="service-00369-88c", - expected_account_scope="live-u16608560", - expected_account_selector=["U16608560"], - expected_deployment_selector="live-u16608560", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", + expected_account_selector=["U00000001"], + expected_deployment_selector="live-primary", ) assert result == { @@ -381,14 +427,14 @@ def open(self, *_args, **_kwargs): source_report_uri="gs://qsl-runtime-reports/ibkr/report-1.json", sync_url=publisher.IBKR_ACCOUNT_FACTS_SYNC_URL, sync_token="dedicated-test-token", - target_id="ibkr-u16608560", + target_id="ibkr-primary", expected_report_prefix="gs://qsl-runtime-reports/ibkr", - expected_project_id="qsl-prod", - expected_service_name="interactive-brokers-quant-live-u16608560-service", - expected_runtime_revision="service-00369-88c", - expected_account_scope="live-u16608560", - expected_account_selector=["U16608560"], - expected_deployment_selector="live-u16608560", + expected_project_id="example-project", + expected_service_name="ibkr-primary-service", + expected_runtime_revision="runtime-revision-001", + expected_account_scope="live-primary", + expected_account_selector=["U00000001"], + expected_deployment_selector="live-primary", ) assert result == { @@ -430,20 +476,26 @@ def open(self, request, *, timeout): monkeypatch.setattr(publisher, "datetime", FrozenDateTime) report = _report() - report["project_id"] = "interactivebrokersquant" + report["project_id"] = "example-project" monkeypatch.setattr( publisher, "_latest_report_uri", lambda **_kwargs: ( - "gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/" - "tqqq_growth_income/live-u16608560/2026-09/20260930T010000Z.json" + "gs://example-private/execution-reports/interactive_brokers/" + "example-profile/live-primary/2026-09/20260930T010000Z.json" ), ) monkeypatch.setattr(publisher, "_load_gcs_report", lambda *_args, **_kwargs: report) monkeypatch.setattr(publisher, "build_opener", lambda *_args: Opener()) - monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", "live-u16608560") - monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560") - monkeypatch.setenv("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION", "service-00369-88c") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", publisher.IBKR_ACCOUNT_FACTS_PRIMARY_TARGET) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://example-private/execution-reports/interactive_brokers/example-profile/live-primary") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET_ID", "ibkr-primary") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_PROJECT_ID", "example-project") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SERVICE_NAME", "ibkr-primary-service") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION", "runtime-revision-001") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE", "live-primary") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON", '["U00000001"]') + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR", "live-primary") monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SYNC_URL", publisher.IBKR_ACCOUNT_FACTS_SYNC_URL) monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SYNC_TOKEN", "TOKEN_SENTINEL") @@ -681,6 +733,20 @@ def test_workflow_ingress_diagnostic_is_manual_and_isolated_from_heartbeat(): assert "run: python3 scripts/publish_account_facts_from_report.py" in diagnostic_job +def test_workflow_scheduled_publisher_is_independent_and_single_target(): + workflow = Path(__file__).parents[1] / ".github/workflows/execution-report-heartbeat.yml" + source = workflow.read_text() + assert "- primary-live" in source + assert "Publish one validated report" not in source.split(" heartbeat:", 1)[1].split(" account-facts-publisher:", 1)[0] + publisher_job = source.split(" account-facts-publisher:", 1)[1].split(" account-facts-ingress-diagnostic:", 1)[0] + assert "github.event_name == 'schedule'" in publisher_job + assert "inputs.account_facts_target == 'primary-live'" in publisher_job + assert "IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON }}" in publisher_job + assert "IBKR_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.IBKR_ACCOUNT_FACTS_REPORT_PREFIX }}" in publisher_job + assert "IBKR_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_TARGET_ID }}" in publisher_job + assert "id-token: write" in publisher_job + + def test_latest_report_listing_is_confined_to_exact_prefix(monkeypatch): seen = [] @@ -694,17 +760,17 @@ def __init__(self, stdout): def run(argv, **_kwargs): seen.append(argv[3]) return Result( - "gs://bucket/root/interactive_brokers/tqqq_growth_income/live-u16608560/2026-09/" + "gs://bucket/root/interactive_brokers/example-profile/live-primary/2026-09/" "20260930T010000Z.json\n" - "gs://bucket/root/interactive_brokers/tqqq_growth_income/live-u16608560-other/2026-09/" + "gs://bucket/root/interactive_brokers/example-profile/live-primary-other/2026-09/" "20260930T010500Z.json\n" ) monkeypatch.setattr(publisher.subprocess, "run", run) uri = publisher._latest_report_uri( - prefix="gs://bucket/root/interactive_brokers/tqqq_growth_income/live-u16608560", + prefix="gs://bucket/root/interactive_brokers/example-profile/live-primary", project_id="project", now=datetime(2026, 9, 30, 1, 10, tzinfo=timezone.utc), ) assert uri.endswith("/2026-09/20260930T010000Z.json") - assert all("live-u16608560/" in value for value in seen) + assert all("live-primary/" in value for value in seen) From cf4850614080d2d1ae15a19cfccbad21384c6b38 Mon Sep 17 00:00:00 2001 From: codex-maintenance <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:38:39 +0800 Subject: [PATCH 2/2] test: validate runtime monitor requirements per job Co-Authored-By: Codex --- tests/test_runtime_monitor_workflows.py | 21 ++++++++++++++++++--- tests/test_uv_dependency_workflow.py | 20 +++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tests/test_runtime_monitor_workflows.py b/tests/test_runtime_monitor_workflows.py index b8fc0ec..975bb96 100644 --- a/tests/test_runtime_monitor_workflows.py +++ b/tests/test_runtime_monitor_workflows.py @@ -1,9 +1,19 @@ +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +def _job_block(workflow: str, name: str) -> str: + start = workflow.index(f" {name}:") + tail = workflow[start:] + next_job = re.search(r"(?m)^ [A-Za-z0-9_-]+:\s*$", tail[len(f" {name}:"):]) + if next_job is None: + return tail + return tail[:len(f" {name}:") + next_job.start()] + + def test_execution_report_heartbeat_has_market_neutral_daily_schedule() -> None: workflow = (ROOT / ".github/workflows/execution-report-heartbeat.yml").read_text() @@ -20,9 +30,14 @@ def test_execution_report_heartbeat_has_market_neutral_daily_schedule() -> None: def test_runtime_monitor_workflows_retry_gcp_authentication() -> None: - for name in ("execution-report-heartbeat.yml", "runtime-guard.yml"): - workflow = (ROOT / ".github/workflows" / name).read_text() - + workflows = { + name: (ROOT / ".github/workflows" / name).read_text() + for name in ("execution-report-heartbeat.yml", "runtime-guard.yml") + } + heartbeat_job = _job_block(workflows["execution-report-heartbeat.yml"], "heartbeat") + runtime_guard = workflows["runtime-guard.yml"] + + for workflow in (heartbeat_job, runtime_guard): assert workflow.count("google-github-actions/auth@v3") == 2 assert "id: gcp_auth_primary" in workflow assert "continue-on-error: true" in workflow diff --git a/tests/test_uv_dependency_workflow.py b/tests/test_uv_dependency_workflow.py index 8430d75..d4dfd26 100644 --- a/tests/test_uv_dependency_workflow.py +++ b/tests/test_uv_dependency_workflow.py @@ -3,6 +3,16 @@ from pathlib import Path +def _job_block(workflow: str, name: str) -> str: + header = f" {name}:" + start = workflow.index(header) + tail = workflow[start:] + next_job = re.search(r"(?m)^ [A-Za-z0-9_-]+:\s*$", tail[len(header):]) + if next_job is None: + return tail + return tail[:len(header) + next_job.start()] + + def test_pyproject_declares_runtime_and_test_dependencies() -> None: pyproject = Path("pyproject.toml").read_text(encoding="utf-8") @@ -25,6 +35,8 @@ def test_ci_docker_and_runtime_monitoring_use_uv_lock() -> None: execution_report_heartbeat = Path(".github/workflows/execution-report-heartbeat.yml").read_text( encoding="utf-8" ) + heartbeat_job = _job_block(execution_report_heartbeat, "heartbeat") + publisher_job = _job_block(execution_report_heartbeat, "account-facts-publisher") lockfile = Path("uv.lock").read_text(encoding="utf-8") assert lockfile.startswith("version = ") @@ -34,7 +46,7 @@ def test_ci_docker_and_runtime_monitoring_use_uv_lock() -> None: assert "uv sync --frozen --no-dev" in env_sync assert "uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json" in env_sync setup_uv = "uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78" - for workflow in (runtime_guard, runtime_target_lifecycle, execution_report_heartbeat): + for workflow in (runtime_guard, runtime_target_lifecycle, heartbeat_job): assert setup_uv in workflow assert workflow.count(setup_uv) == 1 assert workflow.index(setup_uv) < workflow.index("google-github-actions/auth@v3") @@ -45,6 +57,12 @@ def test_ci_docker_and_runtime_monitoring_use_uv_lock() -> None: assert "uv run --no-sync python scripts/cloud_run_runtime_guard.py" in runtime_target_lifecycle assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in runtime_target_lifecycle assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in execution_report_heartbeat + assert "uv run --no-sync python scripts/publish_account_facts_from_report.py" in publisher_job + assert publisher_job.count(setup_uv) == 1 + assert publisher_job.count("uv sync --frozen --no-dev") == 1 + assert publisher_job.count("google-github-actions/auth@v3") == 1 + assert publisher_job.index(setup_uv) < publisher_job.index("google-github-actions/auth@v3") + assert "needs:" not in publisher_job assert "run: python scripts/cloud_run_runtime_guard.py" not in runtime_guard assert " python scripts/cloud_run_runtime_guard.py" not in runtime_target_lifecycle assert 'name = "pandas-market-calendars"' in lockfile