diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index bc9c96ba..88613790 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -30,6 +30,9 @@ on: - disabled - primary-live - ingress-diagnostic + - additional-1 + - additional-2 + - additional-3 account_facts_report_name: description: "Optional exact UTC runtime report filename for account-facts publishing." required: false @@ -45,7 +48,7 @@ concurrency: jobs: heartbeat: name: Check execution report heartbeat - if: ${{ github.event_name != 'workflow_dispatch' || (inputs.account_facts_target != 'ingress-diagnostic' && (inputs.account_facts_target != 'primary-live' || inputs.account_facts_report_name == '')) }} + if: ${{ github.event_name != 'workflow_dispatch' || (inputs.account_facts_target != 'ingress-diagnostic' && ((inputs.account_facts_target != 'primary-live' && inputs.account_facts_target != 'additional-1' && inputs.account_facts_target != 'additional-2' && inputs.account_facts_target != 'additional-3') || inputs.account_facts_report_name == '')) }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -133,7 +136,7 @@ jobs: 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') }} + if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && (inputs.account_facts_target == 'primary-live' || inputs.account_facts_target == 'additional-1' || inputs.account_facts_target == 'additional-2' || inputs.account_facts_target == 'additional-3')) }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -144,7 +147,7 @@ jobs: IBKR_ACCOUNT_FACTS_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_TARGET: ${{ inputs.account_facts_target || 'primary-live' }} IBKR_ACCOUNT_FACTS_REPORT_NAME: ${{ inputs.account_facts_report_name }} IBKR_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.IBKR_ACCOUNT_FACTS_REPORT_PREFIX }} IBKR_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_TARGET_ID }} @@ -167,6 +170,12 @@ jobs: set -euo pipefail uv sync --frozen --no-dev + - name: Select protected additional target before cloud authentication + if: ${{ github.event_name == 'workflow_dispatch' && (inputs.account_facts_target == 'additional-1' || inputs.account_facts_target == 'additional-2' || inputs.account_facts_target == 'additional-3') }} + env: + IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON }} + run: python3 scripts/configure_account_facts_target.py + - name: Authenticate to Google Cloud uses: google-github-actions/auth@v3 with: @@ -178,6 +187,8 @@ jobs: - name: Publish one validated report run: uv run --no-sync python scripts/publish_account_facts_from_report.py + env: + IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON: ${{ (inputs.account_facts_target == 'additional-1' || inputs.account_facts_target == 'additional-2' || inputs.account_facts_target == 'additional-3') && secrets.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON || '' }} account-facts-ingress-diagnostic: name: Verify account-facts ingress authentication only diff --git a/scripts/configure_account_facts_target.py b/scripts/configure_account_facts_target.py new file mode 100644 index 00000000..933060de --- /dev/null +++ b/scripts/configure_account_facts_target.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Export one validated additional IBKR target before cloud authentication.""" + +from __future__ import annotations + +import os + +try: + from scripts.publish_account_facts_from_report import ( + _ProjectionError, + export_additional_target_to_github_env, + ) +except ModuleNotFoundError: + from publish_account_facts_from_report import ( # type: ignore[no-redef] + _ProjectionError, + export_additional_target_to_github_env, + ) + + +def main() -> int: + target = os.environ.get("IBKR_ACCOUNT_FACTS_TARGET", "") + env_path = os.environ.get("GITHUB_ENV", "") + try: + export_additional_target_to_github_env(target, env_path) + except _ProjectionError as exc: + print(f"skipped:{exc.reason}") + return 1 + except Exception: + print("skipped:target_config_invalid") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_account_facts_from_report.py b/scripts/publish_account_facts_from_report.py index cb5a0cde..597d9196 100644 --- a/scripts/publish_account_facts_from_report.py +++ b/scripts/publish_account_facts_from_report.py @@ -31,6 +31,22 @@ IBKR_ACCOUNT_FACTS_MAX_AGE = timedelta(hours=36) IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET = "ingress-diagnostic" IBKR_ACCOUNT_FACTS_PRIMARY_TARGET = "primary-live" +IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV = "IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON" +IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS = frozenset( + {"additional-1", "additional-2", "additional-3"} +) +_ADDITIONAL_TARGET_CONFIG_KEYS = frozenset( + { + "project_id", + "report_prefix", + "target_id", + "service_name", + "runtime_revision", + "account_scope", + "account_selector", + "deployment_selector", + } +) _INGRESS_DIAGNOSTIC_BODY = b"{}" _INGRESS_DIAGNOSTIC_ERROR = "invalid_account_facts_history" _REPORT_RUN_ID = re.compile(r"^\d{8}T\d{6}Z\.json$") @@ -118,6 +134,179 @@ def _expected_account_selector() -> tuple[str, ...]: return selector +def _single_line_text(value: object) -> str: + text = _exact_text(value) + if not text or "\n" in text or "\r" in text: + return "" + return text + + +def _additional_target_configs(raw: str) -> dict[str, dict[str, Any]]: + def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _ProjectionError("target_config_invalid") + result[key] = value + return result + + try: + parsed = json.loads(raw, object_pairs_hook=unique_object) + except (json.JSONDecodeError, TypeError, _ProjectionError): + raise _ProjectionError("target_config_invalid") from None + if not isinstance(parsed, dict) or set(parsed) != IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS: + raise _ProjectionError("target_config_invalid") + + configs: dict[str, dict[str, Any]] = {} + seen_identities: set[tuple[str, ...]] = set() + seen_target_ids: set[str] = set() + seen_prefixes: set[tuple[str, str, str]] = set() + for slot in sorted(IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS): + record = parsed[slot] + if not isinstance(record, dict) or set(record) != _ADDITIONAL_TARGET_CONFIG_KEYS: + raise _ProjectionError("target_config_invalid") + normalized = { + key: _single_line_text(record[key]) + for key in ( + "project_id", + "report_prefix", + "target_id", + "service_name", + "runtime_revision", + "account_scope", + "deployment_selector", + ) + } + selector = _selector(record["account_selector"]) + prefix = normalized["report_prefix"] + try: + parsed_prefix = urlsplit(prefix) + except ValueError: + raise _ProjectionError("target_config_invalid") from None + if ( + any(not value for value in normalized.values()) + or not _TARGET_ID.fullmatch(normalized["target_id"]) + or len(selector) != 1 + or selector[0].lower() == "default" + or "\n" in selector[0] + or "\r" in selector[0] + or parsed_prefix.scheme != "gs" + or not parsed_prefix.netloc + or not parsed_prefix.path.strip("/") + or parsed_prefix.query + or parsed_prefix.fragment + ): + raise _ProjectionError("target_config_invalid") + identity = selector + target_id = normalized["target_id"] + prefix_identity = ( + parsed_prefix.scheme, + parsed_prefix.netloc, + parsed_prefix.path.rstrip("/"), + ) + if ( + identity in seen_identities + or target_id in seen_target_ids + or prefix_identity in seen_prefixes + ): + raise _ProjectionError("target_config_invalid") + seen_identities.add(identity) + seen_target_ids.add(target_id) + seen_prefixes.add(prefix_identity) + normalized["account_selector"] = selector + configs[slot] = normalized + return configs + + +def additional_target_environment(target: str) -> dict[str, str]: + """Resolve one fixed slot into the existing publisher environment contract.""" + raw = os.environ.get(IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV) + if not isinstance(raw, str) or not raw: + raise _ProjectionError("target_config_unavailable") + configs = _additional_target_configs(raw) + if target not in configs: + raise _ProjectionError("target_config_invalid") + config = configs[target] + _reject_primary_target_duplicates(configs) + project_id = config["project_id"] + environment = { + "GCP_PROJECT_ID": project_id, + "IBKR_ACCOUNT_FACTS_PROJECT_ID": project_id, + "IBKR_ACCOUNT_FACTS_TARGET": target, + "IBKR_ACCOUNT_FACTS_REPORT_PREFIX": config["report_prefix"], + "IBKR_ACCOUNT_FACTS_TARGET_ID": config["target_id"], + "IBKR_ACCOUNT_FACTS_SERVICE_NAME": config["service_name"], + "IBKR_ACCOUNT_FACTS_RUNTIME_REVISION": config["runtime_revision"], + "IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE": config["account_scope"], + "IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON": json.dumps( + config["account_selector"], separators=(",", ":") + ), + "IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR": config["deployment_selector"], + } + if environment["GCP_PROJECT_ID"] != environment["IBKR_ACCOUNT_FACTS_PROJECT_ID"]: + raise _ProjectionError("target_config_invalid") + return environment + + +def additional_project_environment(target: str) -> dict[str, str]: + """Resolve and validate the selected project without exporting account identity.""" + raw = os.environ.get(IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV) + if not isinstance(raw, str) or not raw: + raise _ProjectionError("target_config_unavailable") + configs = _additional_target_configs(raw) + if target not in configs: + raise _ProjectionError("target_config_invalid") + _reject_primary_target_duplicates(configs) + project_id = configs[target]["project_id"] + environment = { + "GCP_PROJECT_ID": project_id, + "IBKR_ACCOUNT_FACTS_PROJECT_ID": project_id, + } + if environment["GCP_PROJECT_ID"] != environment["IBKR_ACCOUNT_FACTS_PROJECT_ID"]: + raise _ProjectionError("target_config_invalid") + return environment + + +def _reject_primary_target_duplicates(configs: Mapping[str, Mapping[str, Any]]) -> None: + primary_selector = _expected_account_selector() + primary_target_id = _required_private_setting("IBKR_ACCOUNT_FACTS_TARGET_ID") + primary_prefix = _required_private_setting("IBKR_ACCOUNT_FACTS_REPORT_PREFIX") + try: + parsed_primary_prefix = urlsplit(primary_prefix) + except ValueError: + raise _ProjectionError("target_config_invalid") from None + primary_prefix_identity = ( + parsed_primary_prefix.scheme, + parsed_primary_prefix.netloc, + parsed_primary_prefix.path.rstrip("/"), + ) + for config in configs.values(): + selected_prefix = urlsplit(config["report_prefix"]) + if ( + tuple(config["account_selector"]) == primary_selector + or config["target_id"] == primary_target_id + or ( + selected_prefix.scheme, + selected_prefix.netloc, + selected_prefix.path.rstrip("/"), + ) + == primary_prefix_identity + ): + raise _ProjectionError("target_config_invalid") + + +def export_additional_target_to_github_env(target: str, env_path: str) -> None: + environment = additional_project_environment(target) + if not env_path: + raise _ProjectionError("target_config_unavailable") + print(f"::add-mask::{environment['GCP_PROJECT_ID']}") + with open(env_path, "a", encoding="utf-8") as handle: + for key, value in environment.items(): + if "\n" in value or "\r" in value: + raise _ProjectionError("target_config_invalid") + handle.write(f"{key}={value}\n") + + def _observed_timestamp(value: object) -> datetime: if not isinstance(value, str) or not value.strip(): raise _ProjectionError("observation_invalid") @@ -621,7 +810,9 @@ def main() -> int: ) print(_format_cli_result(result)) return 0 if result.get("status") == "verified" else 1 - if target != IBKR_ACCOUNT_FACTS_PRIMARY_TARGET: + if target in IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS: + os.environ.update(additional_target_environment(target)) + elif target != IBKR_ACCOUNT_FACTS_PRIMARY_TARGET: print("skipped:target_disabled") return 0 prefix = _required_private_setting("IBKR_ACCOUNT_FACTS_REPORT_PREFIX") diff --git a/tests/test_publish_ibkr_account_facts.py b/tests/test_publish_ibkr_account_facts.py index 5f8a281d..a68352fc 100644 --- a/tests/test_publish_ibkr_account_facts.py +++ b/tests/test_publish_ibkr_account_facts.py @@ -67,6 +67,138 @@ def _project(report: dict[str, object]) -> dict[str, object]: ) +def _additional_targets() -> dict[str, dict[str, object]]: + return { + f"additional-{index}": { + "project_id": f"private-project-{index}", + "report_prefix": f"gs://private-bucket/reports/target-{index}", + "target_id": f"private-target-{index}", + "service_name": f"private-service-{index}", + "runtime_revision": f"private-revision-{index}", + "account_scope": f"private-scope-{index}", + "account_selector": [f"U0000001{index}"], + "deployment_selector": f"private-deployment-{index}", + } + for index in range(1, 4) + } + + +@pytest.mark.parametrize( + "mutation", + [ + "missing_slot", + "extra_slot", + "duplicate_identity", + "duplicate_target", + "duplicate_prefix", + "duplicate_prefix_with_slash", + "missing_field", + "extra_field", + "invalid_slot_identity", + ], +) +def test_additional_target_config_is_strict_and_unique(mutation): + config = _additional_targets() + if mutation == "missing_slot": + del config["additional-3"] + elif mutation == "extra_slot": + config["additional-4"] = config["additional-3"] + elif mutation == "duplicate_identity": + config["additional-2"]["account_selector"] = config["additional-1"]["account_selector"] + elif mutation == "duplicate_target": + config["additional-2"]["target_id"] = config["additional-1"]["target_id"] + elif mutation == "duplicate_prefix": + config["additional-2"]["report_prefix"] = config["additional-1"]["report_prefix"] + elif mutation == "duplicate_prefix_with_slash": + config["additional-2"]["report_prefix"] = config["additional-1"]["report_prefix"] + "/" + elif mutation == "missing_field": + del config["additional-1"]["runtime_revision"] + elif mutation == "extra_field": + config["additional-1"]["account_name"] = "must-not-be-accepted" + else: + config["additional-1"]["account_selector"] = ["default"] + + with pytest.raises(publisher._ProjectionError): + publisher._additional_target_configs(json.dumps(config)) + + +def test_additional_target_config_rejects_malformed_and_duplicate_json_keys(): + for raw in ( + "{broken", + '{"additional-1":{},"additional-1":{},"additional-2":{},"additional-3":{}}', + ): + with pytest.raises(publisher._ProjectionError): + publisher._additional_target_configs(raw) + + +def test_additional_target_preflight_rejects_primary_identity_collision(monkeypatch): + config = _additional_targets() + config["additional-1"]["account_selector"] = ["U00000001"] + monkeypatch.setenv( + publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, + json.dumps(config), + ) + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON", '["U00000001"]') + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET_ID", "ibkr-primary") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://example-private/ibkr/reports") + + with pytest.raises(publisher._ProjectionError): + publisher.additional_target_environment("additional-2") + + +@pytest.mark.parametrize("failure", ["missing", "malformed", "duplicate_target", "invalid_slot"]) +def test_additional_target_cli_fails_closed_before_gcs_or_post(monkeypatch, capsys, failure): + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", "additional-1") + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_NAME", "20261001T125959Z.json") + config = _additional_targets() + if failure == "missing": + monkeypatch.delenv(publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, raising=False) + elif failure == "malformed": + monkeypatch.setenv(publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, "{broken") + else: + if failure == "duplicate_target": + config["additional-2"]["target_id"] = config["additional-1"]["target_id"] + monkeypatch.setenv( + publisher.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON_ENV, + json.dumps(config), + ) + if failure == "invalid_slot": + monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", "additional-4") + monkeypatch.setattr( + publisher, + "_latest_report_uri", + lambda **_kwargs: pytest.fail("invalid target config must stop before GCS listing"), + ) + monkeypatch.setattr( + publisher, + "_named_report_uri", + lambda **_kwargs: pytest.fail("invalid target config must stop before report selection"), + ) + monkeypatch.setattr( + publisher, + "_load_gcs_report", + lambda *_args, **_kwargs: pytest.fail("invalid target config must stop before GCS read"), + ) + monkeypatch.setattr( + publisher, + "publish_ibkr_account_facts_history", + lambda *_args, **_kwargs: pytest.fail("invalid target config must stop before POST"), + ) + + result = publisher.main() + + assert result == (0 if failure == "invalid_slot" else 1) + output = capsys.readouterr().out.strip() + assert output == ( + "skipped:target_disabled" + if failure == "invalid_slot" + else "skipped:target_config_unavailable" + if failure == "missing" + else "skipped:target_config_invalid" + ) + assert "private-project" not in output + + def test_projects_bound_ibkr_facts_and_keeps_legacy_receipt_unchanged(): report = _report() history = _project(report) @@ -747,6 +879,9 @@ def test_workflow_scheduled_publisher_is_independent_and_single_target(): 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 "IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON }}" in publisher_job + assert "IBKR_ACCOUNT_FACTS_TARGET: ${{ inputs.account_facts_target || 'primary-live' }}" in publisher_job + assert "strategy:" not in publisher_job assert "id-token: write" in publisher_job assert "IBKR_ACCOUNT_FACTS_REPORT_NAME: ${{ inputs.account_facts_report_name }}" in publisher_job @@ -783,6 +918,8 @@ def _publisher_job_environment() -> dict[str, str]: if (source_name, name) not in placeholders: raise AssertionError(f"unmapped workflow input: {source_name}.{name}") value = placeholders[source_name, name] + elif raw_value == "${{ inputs.account_facts_target || 'primary-live' }}": + value = "primary-live" else: value = raw_value.strip("'\"") environment[key] = value @@ -872,6 +1009,132 @@ def open(self, request, *, timeout): } +def test_additional_target_runs_in_isolated_steps_with_one_private_publish(tmp_path): + environment = _publisher_job_environment() + config = _additional_targets() + environment.update( + { + "IBKR_ACCOUNT_FACTS_TARGET": "additional-2", + "IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON": json.dumps(config), + "GITHUB_ENV": str(tmp_path / "github-env"), + "IBKR_ACCOUNT_FACTS_REPORT_NAME": "20261001T125959Z.json", + } + ) + root = Path(__file__).parents[1] + helper = subprocess.run( + [sys.executable, "scripts/configure_account_facts_target.py"], + cwd=root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert helper.returncode == 0, helper.stderr + assert helper.stdout == "::add-mask::private-project-2\n" + exported = {} + for line in Path(environment["GITHUB_ENV"]).read_text(encoding="utf-8").splitlines(): + key, value = line.split("=", 1) + exported[key] = value + assert set(exported) == {"GCP_PROJECT_ID", "IBKR_ACCOUNT_FACTS_PROJECT_ID"} + assert exported["GCP_PROJECT_ID"] == exported["IBKR_ACCOUNT_FACTS_PROJECT_ID"] + assert exported["IBKR_ACCOUNT_FACTS_PROJECT_ID"] == "private-project-2" + assert environment["IBKR_ACCOUNT_FACTS_TARGET_ID"] == "ibkr-example" + assert environment["IBKR_ACCOUNT_FACTS_REPORT_PREFIX"] == "gs://example-private/ibkr/reports" + assert environment["IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON"] == '["U00000001"]' + environment.update(exported) + + child = r''' +import contextlib, io, json, os +from datetime import datetime, timezone +import scripts.publish_account_facts_from_report as publisher + +class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 10, 1, 13, 0, tzinfo=timezone.utc) +publisher.datetime = FrozenDateTime +selected_config = publisher.additional_target_environment(os.environ["IBKR_ACCOUNT_FACTS_TARGET"]) +prefix = selected_config["IBKR_ACCOUNT_FACTS_REPORT_PREFIX"] +project = selected_config["IBKR_ACCOUNT_FACTS_PROJECT_ID"] +selector = json.loads(selected_config["IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON"]) +report_name = os.environ["IBKR_ACCOUNT_FACTS_REPORT_NAME"] +uri = f"{prefix.rstrip('/')}/2026-10/{report_name}" +report = { + "schema_version": "runtime_report.v1", + "platform": "interactive_brokers", + "deploy_target": "cloud_run", + "project_id": project, + "service_name": selected_config["IBKR_ACCOUNT_FACTS_SERVICE_NAME"], + "account_scope": selected_config["IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE"], + "runtime_target": { + "account_selector": selector, + "deployment_selector": selected_config["IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR"], + }, + "diagnostics": {"runtime_revision": selected_config["IBKR_ACCOUNT_FACTS_RUNTIME_REVISION"]}, + "runtime_release_receipt": {"attestation_state": "legacy_unattested"}, + "started_at": "2026-10-01T12:59:00Z", + "finished_at": "2026-10-01T13:00:00Z", + "summary": {"account_facts": { + "schema_version": "ibkr_account_snapshot.v1", + "account_ids": selector, + "currency": "USD", + "observed_at": "2026-10-01T12:59:30Z", + "net_assets": "123.45", + "cash": [{"currency": "USD", "cash_balance": "12.34", "source_tag": "CashBalance"}], + }}, +} +calls = {"listing": 0, "reports": 0, "posts": 0} +def latest_report_uri(**_kwargs): + calls["listing"] += 1 + raise AssertionError("exact report selection must not list latest reports") +def load_report(requested_uri, *, project_id): + assert requested_uri == uri + assert project_id == os.environ["IBKR_ACCOUNT_FACTS_PROJECT_ID"] + calls["reports"] += 1 + return report +class Response: + status = 200 + def __enter__(self): return self + def __exit__(self, *_args): return False + def read(self, _limit=-1): return b'{"ok":true,"stored":true,"unchanged":false}' +class Opener: + def open(self, request, *, timeout): + calls["posts"] += 1 + assert request.method == "POST" and timeout == 15 + return Response() +publisher._latest_report_uri = latest_report_uri +publisher._load_gcs_report = load_report +publisher.build_opener = lambda *_args: Opener() +captured = io.StringIO() +with contextlib.redirect_stdout(captured): + returncode = publisher.main() +print(json.dumps({"returncode": returncode, "cli": captured.getvalue().strip(), "calls": calls})) +''' + publisher_run = subprocess.run( + [sys.executable, "-c", child], + cwd=root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert publisher_run.returncode == 0, publisher_run.stderr + assert publisher_run.stderr == "" + result = json.loads(publisher_run.stdout) + assert result == { + "returncode": 0, + "cli": "published:unknown", + "calls": { + "listing": 0, + "reports": 1, + "posts": 1, + }, + } + assert helper.stdout == "::add-mask::private-project-2\n" + assert "private-project-2" not in publisher_run.stdout + assert "private-revision-2" not in helper.stdout + publisher_run.stdout + + def test_workflow_explicit_report_name_skips_only_manual_heartbeat(): workflow = Path(__file__).parents[1] / ".github/workflows/execution-report-heartbeat.yml" source = workflow.read_text() @@ -881,9 +1144,19 @@ def test_workflow_explicit_report_name_skips_only_manual_heartbeat(): assert "github.event_name != 'workflow_dispatch'" in heartbeat_if assert "inputs.account_facts_target != 'primary-live'" in heartbeat_if assert "inputs.account_facts_report_name == ''" in heartbeat_if + for slot in ("additional-1", "additional-2", "additional-3"): + assert f"inputs.account_facts_target != '{slot}'" in heartbeat_if 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 + for slot in ("additional-1", "additional-2", "additional-3"): + assert f"inputs.account_facts_target == '{slot}'" in publisher_job + assert publisher_job.index("Select protected additional target before cloud authentication") < publisher_job.index( + "google-github-actions/auth@v3" + ) + assert publisher_job.index("google-github-actions/auth@v3") < publisher_job.index( + "Publish one validated report" + ) def test_latest_report_listing_is_confined_to_exact_prefix(monkeypatch):