diff --git a/.github/workflows/authority-app-auth-check.yml b/.github/workflows/authority-app-auth-check.yml index 4863b5b6..e737590f 100644 --- a/.github/workflows/authority-app-auth-check.yml +++ b/.github/workflows/authority-app-auth-check.yml @@ -8,11 +8,15 @@ permissions: {} jobs: authenticate: + strategy: + max-parallel: 1 + matrix: + environment-permission: [read, write] runs-on: ubuntu-24.04 timeout-minutes: 5 environment: binance-runtime steps: - - name: Mint scoped read-only installation token + - name: Mint scoped maintenance installation token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -21,7 +25,7 @@ jobs: owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-actions: read - permission-environments: read + permission-environments: ${{ matrix.environment-permission }} permission-metadata: read env: # v3.2.0 parses this undeclared input into actions_variables. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e5eeffaf..8341b274 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -622,16 +622,6 @@ jobs: if: ${{ env.RUNTIME_TARGET_ENABLED == 'false' && github.event.inputs.full_cycle == 'true' }} run: echo "Runtime target is disabled; full-cycle validation used read-only broker calls and suppressed all writes." - - name: 5. Stage execution report for isolated log publisher - continue-on-error: true - if: ${{ github.event.inputs.validate_only != 'true' && github.event.inputs.reconcile_only != 'true' && env.RUNTIME_TARGET_ENABLED == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: binance-execution-report-${{ github.run_id }} - path: reports/execution_report.json - if-no-files-found: warn - retention-days: 1 - - name: 5a. Stage redacted lifecycle recorder output for monitor continue-on-error: true if: ${{ always() && github.event.inputs.validate_only != 'true' && github.event.inputs.reconcile_only != 'true' && env.RUNTIME_TARGET_ENABLED == 'true' }} @@ -714,58 +704,3 @@ jobs: TG_TOKEN: ${{ secrets.TG_TOKEN }} GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} - publish-execution-log: - name: Publish execution log without broker credentials - needs: deploy - if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.runtime_target_enabled == 'true' && github.event.inputs.validate_only != 'true' && github.event.inputs.reconcile_only != 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - actions: read - contents: write - steps: - - name: Download staged execution report - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: binance-execution-report-${{ github.run_id }} - path: reports - - - name: Push execution log to logs branch - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - LOGS_BRANCH="logs" - TS="$(date -u +%Y-%m-%dT%H%M)" - MONTH="${TS:0:7}" - REPORT_FILE="${GITHUB_WORKSPACE}/reports/execution_report.json" - - if [ ! -f "$REPORT_FILE" ]; then - echo "No execution report found, skipping log push." - exit 0 - fi - - TMP_DIR=$(mktemp -d) - trap 'rm -rf "$TMP_DIR"' EXIT - git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${TMP_DIR}/logs-repo" - cd "${TMP_DIR}/logs-repo" - - if git ls-remote --exit-code --heads origin "${LOGS_BRANCH}" >/dev/null 2>&1; then - git fetch origin "${LOGS_BRANCH}:${LOGS_BRANCH}" - git checkout "${LOGS_BRANCH}" - else - git checkout --orphan "${LOGS_BRANCH}" - git rm -rf . >/dev/null 2>&1 || true - find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + - fi - - mkdir -p "hourly/${MONTH}" - cp "$REPORT_FILE" "hourly/${MONTH}/${TS}.json" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add "hourly/${MONTH}/${TS}.json" - git commit -m "execution: ${TS}" - git push origin "HEAD:${LOGS_BRANCH}" diff --git a/application/cycle_service.py b/application/cycle_service.py index 70d3a11d..41e40de3 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -11,8 +11,10 @@ from quant_platform_kit.common.runtime_reports import persist_runtime_report from quant_platform_kit.strategy_lifecycle.performance_monitor import ( + PerformanceMonitor, + infer_strategy_domain, + logger as _performance_monitor_logger, resolve_lifecycle_stream_id, - try_record_platform_execution, ) from application.execution_receipt_adapter import attach_execution_receipt_from_report from application.execution_kernel_adapter import consult_t2_unknown_new_cycle @@ -26,6 +28,25 @@ ) +def try_record_platform_execution( + profile_id, execution_result, *, domain="", decision=None, stream_id="", +): + """Preserve the pinned recorder's best-effort behavior without raw errors.""" + try: + if not execution_result: + return + monitor = PerformanceMonitor() + monitor.record_execution( + profile_id, + execution_result, + domain=infer_strategy_domain(profile_id, explicit_domain=domain), + decision=decision, + stream_id=stream_id, + ) + except Exception: # noqa: BLE001 - preserve the recorder's best-effort boundary + _performance_monitor_logger.warning("platform_execution_record_failed") + + def _record_risk_diagnostics(report, allocation): assessment = allocation.get("risk_assessment") if not isinstance(assessment, Mapping): @@ -69,8 +90,12 @@ def _build_platform_execution_result(report, *, state_healthy, state_owner_relea } +def _public_cycle_status(status): + return status if isinstance(status, str) and status in {"ok", "aborted", "error"} else "unknown" + + def _write_lifecycle_export(profile_id, execution_result): - """Best-effort, redacted export of the current recorder payload. + """Best-effort public health metadata, without financial recorder fields. The normal PerformanceStore path remains authoritative. This optional file is a short-lived transport handoff for the monitor and must never @@ -91,18 +116,13 @@ def _write_lifecycle_export(profile_id, execution_result): profile = str(profile_id or "").strip() if not profile: return - stream_id = resolve_lifecycle_stream_id(execution_result=execution_result) + stream_id = resolve_lifecycle_stream_id(execution_result={"platform": "binance"}) safe_result = { - key: execution_result.get(key) - for key in ( - "platform", - "status", - "total_equity_usdt", - "trend_equity_usdt", - "external_cash_flow", - "external_cash_flow_interval", - "degraded_mode_level", - ) + "platform": "binance", + "status": _public_cycle_status(execution_result.get("status")), + # Legacy consumers treat an absent flow as zero. Explicit null + # preserves unknown performance evidence without exposing values. + "external_cash_flow": None, } if safe_result.get("status") != "ok": safe_result["error_code"] = "cycle_failed" @@ -654,10 +674,10 @@ def run_live_cycle( printer=output_printer, ) report = execute_cycle(runtime) - output_printer("\n".join(report.get("log_lines", []))) + # Free-form portfolio lines belong only in the existing private report. + output_printer("Strategy report details retained in the execution report.") report_path = report_writer(report) persisted_local_path = report_path - persisted_cloud_uri = None try: persisted = persist_runtime_report( report, @@ -666,16 +686,12 @@ def run_live_cycle( project_id=os.getenv("CLOUD_PROJECT_ID") or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT"), ) persisted_local_path = persisted.local_path or report_path - if hasattr(persisted, "cloud_uri"): - persisted_cloud_uri = persisted.cloud_uri - else: - persisted_cloud_uri = getattr(persisted, "gcs_uri", None) except Exception: report["status"] = "error" append_report_error(report, "report_persistence_failed", stage="report_persistence") output_printer("failed to persist archived execution report: report_persistence_failed") persisted_local_path = report_writer(report) - report_status = str(report.get("status", "unknown")) + report_status = _public_cycle_status(report.get("status")) status_event = { "ok": "strategy_cycle_completed", "aborted": "strategy_cycle_aborted", @@ -687,12 +703,10 @@ def run_live_cycle( severity="INFO" if report_status in {"ok", "aborted"} else "ERROR", printer=output_printer, status=report_status, - report_path=persisted_local_path, - report_cloud_uri=persisted_cloud_uri, - total_equity_usdt=report.get("total_equity_usdt"), - trend_equity_usdt=report.get("trend_equity_usdt"), - degraded_mode_level=report.get("degraded_mode_level"), - circuit_breaker_triggered=report.get("circuit_breaker_triggered"), + circuit_breaker_triggered=( + report.get("circuit_breaker_triggered") + if isinstance(report.get("circuit_breaker_triggered"), bool) else None + ), error_count=len(report.get("error_summary", {}).get("errors", [])), ) diff --git a/docs/authority_reissue.md b/docs/authority_reissue.md index 681009e6..3112dc1c 100644 --- a/docs/authority_reissue.md +++ b/docs/authority_reissue.md @@ -63,7 +63,9 @@ After that adoption, dispatch `authority-app-auth-check.yml` on `runtime-production` to verify installation-token creation, repository scope, and configuration reads. This separate manual check does not read the authority JSON, write configuration, or dispatch trading; it requires no authority-update -inputs and can run without stopping a trading runtime. +inputs and can run without stopping a trading runtime. It serially mints the +read and write Environment permission variants used by plan and apply, while +performing only read requests with both tokens. Before adopting a maintenance change on `runtime-production`, compare its current revision with `BINANCE_RUNTIME_WORKFLOW_SHA`. Updating the branch also @@ -71,6 +73,12 @@ changes the runtime workflow revision. Do not advance the approved runtime pin or include unrelated runtime changes merely to validate authentication; those changes require their own approved release decision. +An authentication-only adoption must leave `.github/workflows/main.yml`, +runtime source, and the approved runtime pins unchanged. If the production +branch already differs from the approved workflow pin, that mismatch remains +a separate runtime release issue; token verification does not resolve it or +authorize execution. + ## Explicit inputs (no main-tip selection) All of the following must be supplied as full lowercase hex values: diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index 79c94912..af23a3cd 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -169,6 +169,41 @@ The monthly execution pool is locked to the accepted upstream `version` / `as_of - Repository variable changes are consumed by the next externally scheduled dispatch; they do not reconfigure the VPS scheduler cadence. - Avoid overlapping dispatches from multiple schedulers or from a second manual run while the current runtime job is still in progress. +### Optional native account facts: separate manual capability + +The automatic `Runtime` workflow does not attach the optional native +account-facts collector. Its original workflow-wide concurrency lock, strategy +job, and isolated execution-log publisher remain unchanged. Waiting for optional +account-data approval must not become part of an automatic trading run. + +`Binance Account Facts` remains a separate protected manual workflow. Its existing +manual inputs are `enabled` (default `false`), an exact `source_run_id`, and +`source_mode=legacy_terminal`. The repository gate, `runtime-production` ref, +`binance-runtime` environment approval, trusted reader revision, source checks, +and isolated publication token still apply. Retaining this entry does not grant +permission to invoke it or relax its checks. + +Use it only during an explicitly established quiet maintenance window: Runtime +workflow admission remains disabled, no active or runnable Runtime attempt exists, +and no other operator or controller can re-enable, rerun, or dispatch trading. +Its separate concurrency group and point-in-time source checks do not provide an +atomic read-to-publication fence against a later Runtime. Actions-level inactivity +does not establish host-process or broker-order inactivity. + +The reader also requires the latest qualifying Runtime source to be terminal +success with its exact approved revisions and report. At the 2026-10-06 +maintenance checkpoint, the latest queued run and old waiting parent were +cancelled, so no eligible latest successful source was established. Do not create +one by enabling or dispatching a strategy merely to fill the account-data gap; +do not make the reader accept cancellation or stale sources. + +Automatic native account facts are not yet a completed data path. Preserve +historical observation timestamps and unavailable/stale states rather than +presenting old data as fresh or missing data as zero. Reliable asynchronous +collection and receiver-side run/session fencing require separate review. +Source publication, workflow-pin adoption, and resuming trading are separate +decisions; this detachment itself authorizes none of them. + ### Approved runtime release pin `BINANCE_RUNTIME_RELEASE_SHA` is the single protected repository variable that @@ -819,3 +854,41 @@ returns `blocked` and exit 2. All outcomes keep full account reconciliation and execution authority false, including when out-of-scope Spot assets are present. Stop on the first real failure; do not initialize a cursor or activate trading to make this diagnostic pass. A successful preview is not durable accounting. + + +## Public diagnostic output privacy + +The Runtime workflow must not upload the complete `execution_report.json` to +GitHub Actions or copy it into the public `logs` branch. Its former full-report +upload and isolated repository log-publisher job are removed. Existing local and +configured private runtime-report persistence remain unchanged; this change +creates no storage destination, identity or permission. + +Public lifecycle export and console completion records contain bounded health +metadata only. They omit account valuation, cash-flow amounts, account-scope digests, +free-form portfolio lines and nested financial objects. The private report and +PerformanceStore recorder keep their existing content. Missing financial values +in public diagnostics mean unavailable, never zero or a substitute account NAV. +The public lifecycle payload explicitly keeps `external_cash_flow: null`, since +legacy consumers otherwise interpret an absent cash-flow field as zero. + +The standalone protected account-facts workflow remains unchanged. Its exact +source-artifact requirement now makes new Runtime sources unavailable: the +preflight or required download must fail before native-account reading and +publication. Do not bypass that check, substitute an old report, or enable a +strategy to manufacture a source. Restoring this optional reader needs a +separately reviewed private handoff; automatic native account facts remain open. + +This source change is not a deployment or permission to restore trading. +Runtime stays manually disabled. Both the workflow and application revisions +must be reviewed and adopted through the existing protected process before any +future operator recovery; deploying the workflow alone can still upload an old +application's unsanitized lifecycle file. Do not change pins or enable Runtime +as part of this patch. + +Deleting existing Actions artifacts or job logs is separate from this source +fix and requires an exact operator decision because that evidence cannot be +restored normally. Public Git copies and history, clones and prior downloads +are not removed by artifact/log deletion. Keep only non-financial audit metadata +(run/artifact/job identifiers, timestamps, source revisions and field-presence +booleans) in the incident summary; do not copy amounts or account-scope hashes. diff --git a/scripts/runtime_isolation_host_probe.py b/scripts/runtime_isolation_host_probe.py index 070e3d6f..a6fd9b38 100644 --- a/scripts/runtime_isolation_host_probe.py +++ b/scripts/runtime_isolation_host_probe.py @@ -55,11 +55,14 @@ def inspect_secret_source(workflow_path: Path) -> dict[str, Any]: workflow = workflow_path.read_text(encoding="utf-8") key_ref = "BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" secret_ref = "BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }}" - strategy_marker = "- name: 4. Run trading strategy" - next_marker = "- name: 5. Stage execution report" + strategy_marker = " - name: 4. Run trading strategy" + next_marker = " - name: Remove protected LIVE risk authority source" strategy_block = "" - if strategy_marker in workflow and next_marker in workflow: - strategy_block = workflow[workflow.index(strategy_marker) : workflow.index(next_marker)] + if workflow.count(strategy_marker) == 1 and workflow.count(next_marker) == 1: + candidate = workflow[workflow.index(strategy_marker) : workflow.index(next_marker)] + # The end marker must be the very next step, never a broader job slice. + if not any(line.startswith(" - ") for line in candidate.splitlines()[1:]): + strategy_block = candidate references_present = key_ref in workflow and secret_ref in workflow return { "source": "github_actions_environment_secrets" if references_present else "UNVERIFIED", @@ -68,6 +71,7 @@ def inspect_secret_source(workflow_path: Path) -> dict[str, Any]: "broker_secret_scope": ( "strategy_step_only" if key_ref in strategy_block and secret_ref in strategy_block + and workflow.count(key_ref) == 1 and workflow.count(secret_ref) == 1 else "UNVERIFIED" ), "secret_values_read": False, diff --git a/tests/test_authority_reissue_workflow.py b/tests/test_authority_reissue_workflow.py index 956b1bd6..77898e0a 100644 --- a/tests/test_authority_reissue_workflow.py +++ b/tests/test_authority_reissue_workflow.py @@ -70,3 +70,23 @@ def test_manual_inputs_and_existing_stop_contract_remain() -> None: assert f" {name}:\n description:" in workflow assert "RUNTIME_TARGET_ENABLED` live value is exactly `false`" in docs assert "first failure or uncertain outcome: stop" in docs + + +def test_authentication_check_covers_both_modes_without_authority_or_writes() -> None: + workflow = WORKFLOW.with_name("authority-app-auth-check.yml").read_text(encoding="utf-8") + + assert "environment-permission: [read, write]" in workflow + assert "max-parallel: 1" in workflow + assert "permission-environments: ${{ matrix.environment-permission }}" in workflow + assert "repositories: ${{ github.event.repository.name }}" in workflow + assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in workflow + assert "skip-token-revoke:" not in workflow + assert "BINANCE_RISK_AUTHORITY_JSON" not in workflow + assert "BINANCE_AUTHORITY_UPDATE_TOKEN" not in workflow + assert "actions/checkout" not in workflow + for method in ("POST", "PUT", "PATCH", "DELETE"): + assert f"--method {method}" not in workflow + assert f"-X {method}" not in workflow + assert "scripts/update_binance_authority_reissue.py" not in workflow + assert "main.py" not in workflow + assert "schedule:" not in workflow diff --git a/tests/test_cycle_service.py b/tests/test_cycle_service.py index 670a2055..ac2570c3 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -13,6 +13,7 @@ _write_lifecycle_export, execute_strategy_cycle, run_live_cycle, + try_record_platform_execution, write_execution_report, ) from application.execution_service import execute_trend_buys @@ -309,6 +310,56 @@ def test_full_cycle_publishes_heartbeat_only_once(self): self.assertIn("state_write", events) monitor.beat.assert_called_once_with(status="ok", error="") + def test_public_safe_recorder_preserves_private_call_and_return_contract(self): + payload = {"platform": "binance", "total_equity_usdt": 918273.625} + decision = object() + with patch("application.cycle_service.PerformanceMonitor") as monitor: + result = try_record_platform_execution( + "crypto_live_pool_rotation", payload, domain="crypto", + decision=decision, stream_id="binance", + ) + self.assertIsNone(result) + monitor.assert_called_once_with() + monitor.return_value.record_execution.assert_called_once_with( + "crypto_live_pool_rotation", payload, domain="crypto", + decision=decision, stream_id="binance", + ) + self.assertIs(monitor.return_value.record_execution.call_args.args[1], payload) + with patch("application.cycle_service.PerformanceMonitor") as monitor: + self.assertIsNone(try_record_platform_execution("crypto_live_pool_rotation", None)) + monitor.assert_not_called() + + def test_public_safe_recorder_replaces_constructor_and_write_exception_details(self): + sentinel = "SYNTHETIC_PRIVATE_STORE_EXCEPTION" + for failure_stage in ("constructor", "write"): + with self.subTest(failure_stage=failure_stage): + with ( + patch("application.cycle_service.PerformanceMonitor") as monitor, + patch("application.cycle_service._performance_monitor_logger.warning") as warning, + ): + if failure_stage == "constructor": + monitor.side_effect = RuntimeError(sentinel) + else: + monitor.return_value.record_execution.side_effect = RuntimeError(sentinel) + self.assertIsNone(try_record_platform_execution( + "crypto_live_pool_rotation", {"platform": "binance"}, + )) + warning.assert_called_once_with("platform_execution_record_failed") + self.assertNotIn(sentinel, str(warning.call_args)) + + def test_public_safe_recorder_failure_does_not_change_real_cycle_result(self): + def rebase(*args): + args[2]["status"] = "ok" + + with ( + patch("application.cycle_service.PerformanceMonitor", side_effect=RuntimeError("SYNTHETIC_PRIVATE_STORE_EXCEPTION")), + patch("application.cycle_service._performance_monitor_logger.warning") as warning, + ): + report, events = self._run_funds_cycle(True, rebase_fn=rebase) + self.assertEqual(report["status"], "ok") + self.assertIn("state_write", events) + warning.assert_called_once_with("platform_execution_record_failed") + def test_platform_performance_record_marks_external_cash_flow_incomparable(self): with patch("application.cycle_service.try_record_platform_execution") as record: self._run_funds_cycle(True) @@ -359,12 +410,88 @@ def test_lifecycle_export_is_optional_and_redacts_provider_details(self): self.assertEqual(payload["domain"], "crypto") self.assertEqual(payload["record_kind"], "execution") self.assertEqual(payload["lifecycle_stream_id"], "binance") - self.assertIsNone(payload["execution_result"]["external_cash_flow_interval"]) + self.assertNotIn("external_cash_flow_interval", payload["execution_result"]) self.assertEqual(payload["execution_result"]["error_code"], "cycle_failed") self.assertNotIn("provider-secret-must-not-export", json.dumps(payload)) self.assertNotIn("orders", json.dumps(payload)) self.assertEqual(os.stat(export_path).st_mode & 0o777, 0o600) + def test_public_lifecycle_export_omits_financial_values_and_nested_details(self): + sentinel = "SYNTHETIC_PRIVATE_VALUE_DO_NOT_PUBLISH" + execution_result = { + "platform": "binance", + "status": "ok", + "total_equity_usdt": 918273.625, + "trend_equity_usdt": 817263.125, + "external_cash_flow": sentinel, + "external_cash_flow_interval": { + "end_equity_usdt": sentinel, + "net_external_cash_flow": sentinel, + "account_scope_sha256": sentinel, + "nested": {"unknown": sentinel}, + }, + "degraded_mode_level": {"details": sentinel}, + "error": sentinel, + "unknown": sentinel, + } + original = copy.deepcopy(execution_result) + with tempfile.TemporaryDirectory() as directory: + export_path = os.path.join(directory, "lifecycle-run.json") + with patch.dict(os.environ, {"BINANCE_LIFECYCLE_EXPORT_PATH": export_path}): + _write_lifecycle_export("crypto_live_pool_rotation", execution_result) + with open(export_path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertEqual(payload["execution_result"], { + "platform": "binance", "status": "ok", "external_cash_flow": None, + }) + self.assertNotIn(sentinel, json.dumps(payload)) + self.assertNotIn("918273.625", json.dumps(payload)) + self.assertNotIn("817263.125", json.dumps(payload)) + self.assertEqual(execution_result, original) + + def test_public_lifecycle_export_does_not_copy_unexpected_status_payloads(self): + sentinel = "SYNTHETIC_PRIVATE_STATUS_DO_NOT_PUBLISH" + for status in (sentinel, {"detail": sentinel}, [sentinel], None): + with self.subTest(status_type=type(status).__name__): + with tempfile.TemporaryDirectory() as directory: + export_path = os.path.join(directory, "lifecycle-run.json") + with patch.dict(os.environ, {"BINANCE_LIFECYCLE_EXPORT_PATH": export_path}): + _write_lifecycle_export( + "crypto_live_pool_rotation", + {"platform": sentinel, "status": status}, + ) + with open(export_path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertEqual(payload["execution_result"], { + "platform": "binance", "status": "unknown", "error_code": "cycle_failed", + "external_cash_flow": None, + }) + self.assertNotIn(sentinel, json.dumps(payload)) + + def test_public_lifecycle_is_unavailable_to_pinned_return_consumer(self): + from quant_platform_kit.strategy_lifecycle.live_equity import ( + extract_equity_value, + extract_external_cash_flow, + live_run_records_to_return_series_result, + ) + + with tempfile.TemporaryDirectory() as directory: + export_path = os.path.join(directory, "lifecycle-run.json") + with patch.dict(os.environ, {"BINANCE_LIFECYCLE_EXPORT_PATH": export_path}): + _write_lifecycle_export("crypto_live_pool_rotation", { + "platform": "binance", "status": "ok", + "total_equity_usdt": 918273.625, + "external_cash_flow_interval": {"end_equity_usdt": "918273.625"}, + }) + with open(export_path, encoding="utf-8") as handle: + payload = json.load(handle) + self.assertIsNone(extract_equity_value(payload)) + self.assertIsNone(extract_external_cash_flow(payload)) + records = [dict(payload, recorded_at=f"2026-10-0{day}T12:00:00Z") for day in (5, 6)] + result = live_run_records_to_return_series_result(records, domain="crypto") + self.assertTrue(result.series.empty) + self.assertEqual(result.status, "insufficient_observations") + def test_lifecycle_export_without_authorized_path_does_not_write(self): with tempfile.TemporaryDirectory() as directory: export_path = os.path.join(directory, "lifecycle-run.json") @@ -749,7 +876,7 @@ def fake_execute_cycle(runtime): self.assertEqual(observed["built"], 1) self.assertEqual(len(observed["printed"]), 3) - self.assertEqual(observed["printed"][1], "line-1\nline-2") + self.assertEqual(observed["printed"][1], "Strategy report details retained in the execution report.") self.assertEqual(report["status"], "ok") self.assertEqual(payload["log_lines"], ["line-1", "line-2"]) @@ -803,6 +930,90 @@ def test_run_live_cycle_emits_structured_runtime_events(self): self.assertEqual(end_log["event"], "strategy_cycle_completed") self.assertEqual(end_log["status"], "ok") + def test_public_cycle_stdout_omits_financial_data_but_preserves_private_report(self): + sentinel = "SYNTHETIC_PRIVATE_REPORT_DO_NOT_PUBLISH" + report = { + "status": "ok", + "total_equity_usdt": 918273.625, + "trend_equity_usdt": 817263.125, + "external_cash_flow_interval": {"end_equity_usdt": sentinel}, + "log_lines": [sentinel], + "degraded_mode_level": {"detail": sentinel}, + "circuit_breaker_triggered": {"detail": sentinel}, + "error_summary": {"errors": []}, + } + original = copy.deepcopy(report) + printed = [] + archived = [] + with tempfile.TemporaryDirectory() as directory: + with patch( + "application.cycle_service.persist_runtime_report", + side_effect=lambda value, **kwargs: archived.append(copy.deepcopy(value)) + or SimpleNamespace(local_path=kwargs["output_path"], cloud_uri=sentinel), + ): + returned, output_path = run_live_cycle( + runtime_builder=lambda: SimpleNamespace(run_id="public-test", dry_run=False), + execute_cycle=lambda _runtime: report, + output_printer=printed.append, + report_writer=lambda value: write_execution_report( + value, reports_dir=directory, filename=f"{sentinel}.json", + ), + ) + with open(output_path, encoding="utf-8") as handle: + persisted = json.load(handle) + public_output = "\n".join(printed) + for private_value in (sentinel, "918273.625", "817263.125"): + self.assertNotIn(private_value, public_output) + completion = json.loads(printed[-1]) + self.assertEqual(completion["event"], "strategy_cycle_completed") + self.assertEqual(completion["status"], "ok") + for field in ("total_equity_usdt", "trend_equity_usdt", "degraded_mode_level", + "report_path", "report_cloud_uri", "circuit_breaker_triggered"): + self.assertNotIn(field, completion) + self.assertEqual(returned, original) + self.assertEqual(persisted, original) + self.assertEqual(archived, [original]) + + def test_public_stdout_with_real_cycle_keeps_nested_financial_evidence_private(self): + from contextlib import redirect_stderr, redirect_stdout + from io import StringIO + + sentinel = "SYNTHETIC_CYCLE_PRIVATE_DO_NOT_PUBLISH" + private_interval = {"end_equity_usdt": sentinel, "account_scope_sha256": sentinel} + observed = {} + + def rebase(*args): + args[2]["status"] = "ok" + args[2]["external_cash_flow_interval"] = private_interval + args[6].append(sentinel) + + def run_real_cycle(_runtime): + report, events = self._run_funds_cycle(True, rebase_fn=rebase) + observed["events"] = events + return report + + output = StringIO() + with tempfile.TemporaryDirectory() as directory: + with ( + patch("application.cycle_service.try_record_platform_execution") as recorder, + patch("application.cycle_service.persist_runtime_report", + return_value=SimpleNamespace(local_path=None, cloud_uri=None)), + redirect_stdout(output), redirect_stderr(output), + ): + report, output_path = run_live_cycle( + runtime_builder=lambda: SimpleNamespace(dry_run=False), + execute_cycle=run_real_cycle, + report_writer=lambda value: write_execution_report(value, reports_dir=directory), + ) + with open(output_path, encoding="utf-8") as handle: + persisted = json.load(handle) + self.assertNotIn(sentinel, output.getvalue()) + self.assertIn("state_write", observed["events"]) + self.assertEqual(report["status"], "ok") + self.assertEqual(persisted["external_cash_flow_interval"], private_interval) + self.assertIn(sentinel, persisted["log_lines"]) + self.assertEqual(recorder.call_args.args[1]["external_cash_flow_interval"], private_interval) + def test_run_live_cycle_uses_shared_runtime_report_archive(self): observed = {} diff --git a/tests/test_runtime_isolation_host_probe.py b/tests/test_runtime_isolation_host_probe.py index ab1a4e6f..5ece7591 100644 --- a/tests/test_runtime_isolation_host_probe.py +++ b/tests/test_runtime_isolation_host_probe.py @@ -37,6 +37,45 @@ def test_current_secret_source_is_github_environment_and_step_scoped(self) -> No self.assertEqual(result["broker_secret_scope"], "strategy_step_only") self.assertFalse(result["secret_values_read"]) + def _inspect_synthetic_workflow(self, text: str): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "workflow.yml" + path.write_text(text, encoding="utf-8") + return self.probe.inspect_secret_source(path) + + def test_strategy_secret_boundary_requires_unique_adjacent_step_markers(self) -> None: + workflow = MAIN_WORKFLOW.read_text(encoding="utf-8") + start = " - name: 4. Run trading strategy" + end = " - name: Remove protected LIVE risk authority source" + variants = { + "missing_start": workflow.replace(start, " - name: Unrecognized strategy"), + "missing_end": workflow.replace(end, " - name: Unrecognized cleanup"), + "duplicate_start": workflow + "\n" + start + "\n", + "duplicate_end": workflow + "\n" + end + "\n", + "intervening_named_step": workflow.replace(end, " - name: Unrecognized step\n" + end), + "intervening_unnamed_step": workflow.replace(end, " - uses: example/action@v1\n" + end), + } + for case, text in variants.items(): + with self.subTest(case=case): + result = self._inspect_synthetic_workflow(text) + self.assertEqual(result["broker_secret_scope"], "UNVERIFIED") + self.assertFalse(result["secret_values_read"]) + + def test_cross_step_secret_references_are_not_claimed_strategy_only(self) -> None: + workflow = MAIN_WORKFLOW.read_text(encoding="utf-8") + key = " BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" + secret = " BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }}" + later_step = "\n - name: Other synthetic step\n env:\n" + key + "\n" + secret + "\n" + for case, text in { + "moved": workflow.replace(key + "\n", "").replace(secret + "\n", "") + later_step, + "duplicated": workflow + later_step, + }.items(): + with self.subTest(case=case): + result = self._inspect_synthetic_workflow(text) + self.assertTrue(result["broker_secret_references_present"]) + self.assertEqual(result["broker_secret_scope"], "UNVERIFIED") + self.assertFalse(result["secret_values_read"]) + def test_provider_inference_does_not_assume_gce(self) -> None: self.assertEqual( self.probe.infer_host_provider({"sys_vendor": "OracleCloud.com"}), diff --git a/tests/test_runtime_workflow_security.py b/tests/test_runtime_workflow_security.py index e931aa8f..bbec998b 100644 --- a/tests/test_runtime_workflow_security.py +++ b/tests/test_runtime_workflow_security.py @@ -19,6 +19,109 @@ def _job_block(workflow: str, job: str, next_job: str | None = None) -> str: return workflow[start:end] +def test_runtime_does_not_automatically_run_optional_account_facts() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + jobs = workflow.split("\njobs:\n", 1)[1] + assert re.findall(r"^ ([a-z][a-z0-9-]*):$", jobs, re.MULTILINE) == [ + "deploy", + ] + assert "account-facts-readonly:" not in jobs + assert "/.github/workflows/binance-account-facts.yml@" not in jobs + + +def test_runtime_never_uploads_full_execution_report_or_publishes_logs_branch() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "binance-execution-report-" not in workflow + assert "publish-execution-log:" not in workflow + assert 'LOGS_BRANCH="logs"' not in workflow + assert "contents: write" not in workflow + assert "EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}" in workflow + + +def test_manual_native_reader_remains_fail_closed_without_report_artifact() -> None: + workflow = WORKFLOW.with_name("binance-account-facts.yml").read_text(encoding="utf-8") + download = workflow.split( + " - name: Download only the exact successful Runtime report artifact", 1 + )[1].split(" - name: Read the bound Spot and Flexible Earn account quantities", 1)[0] + assert "actions/download-artifact@" in download + assert "binance-execution-report-${{ steps.preflight.outputs.source_run_id }}" in download + assert "continue-on-error" not in download + assert "if: ${{ inputs.source_mode != 'direct_read' }}" in download + + legacy_read = workflow.split( + " - name: Read the bound Spot and Flexible Earn account quantities", 1 + )[1].split(" - name: Read directly from the protected account binding", 1)[0] + assert "if: ${{ inputs.source_mode != 'direct_read' }}" in legacy_read + assert "--report \"$FACTS_DIR/source/execution_report.json\"" in legacy_read + + direct_read = workflow.split( + " - name: Read directly from the protected account binding", 1 + )[1].split(" - name: Recheck Runtime source before publishing", 1)[0] + assert "actions/download-artifact@" not in direct_read + assert "if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_mode == 'direct_read' }}" in direct_read + assert "--direct-read" in direct_read + + assert workflow.index("Verify trusted reader checkout and source mode") < ( + workflow.index("Download only the exact successful Runtime report artifact") + ) < workflow.index("Read the bound Spot and Flexible Earn account quantities") + + +def test_runtime_keeps_workflow_lock_after_optional_reader_detachment() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + before_jobs = workflow.split("\njobs:\n", 1)[0] + assert workflow.startswith("name: Runtime\n") + assert ( + "\nconcurrency:\n" + " group: ${{ github.workflow }}-${{ github.ref_name }}\n" + " cancel-in-progress: false\n" + ) in before_jobs + assert "\n concurrency:\n" not in workflow + + +def test_manual_native_facts_entry_remains_opt_in_and_source_bound() -> None: + workflow = WORKFLOW.with_name("binance-account-facts.yml").read_text(encoding="utf-8") + dispatch = workflow.split(" workflow_dispatch:\n", 1)[1].split("\npermissions:\n", 1)[0] + enabled = dispatch.split(" enabled:\n", 1)[1].split(" source_run_id:\n", 1)[0] + source_mode = dispatch.split(" source_mode:\n", 1)[1] + assert " type: boolean\n" in enabled + assert " default: false\n" in enabled + assert " source_run_id:\n" in dispatch + assert " type: choice\n" in source_mode + assert " options: [direct_read, legacy_terminal]\n" in source_mode + assert " schedule:" not in workflow + assert "vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true'" in workflow + assert "github.ref == 'refs/heads/runtime-production'" in workflow + assert "github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch'" in workflow + assert "inputs.enabled == true && inputs.source_mode == 'legacy_terminal'" in workflow + job = workflow.split(" read-and-publish:\n", 1)[1].split(" runs-on:", 1)[0] + assert "vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true'" in job + assert "inputs.enabled == true && inputs.source_mode == 'direct_read'" in job + assert "inputs.enabled == true && inputs.source_mode == 'legacy_terminal'" in job + assert "inputs.enabled == true && inputs.source_mode == 'same_parent'" in job + assert "github.ref == 'refs/heads/runtime-production'" in job + assert " environment: binance-runtime\n" in workflow + assert " group: binance-account-facts-readonly\n cancel-in-progress: false\n" in workflow + assert " ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }}\n" in workflow + assert "scripts/read_binance_account_facts.py --preflight" in workflow + assert "scripts/read_binance_account_facts.py --verify-current-source" in workflow + assert "SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }}" in workflow + assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}" in workflow + direct_read = workflow.split( + " - name: Read directly from the protected account binding", 1 + )[1].split(" - name: Recheck Runtime source before publishing", 1)[0] + for trade_or_runtime_gate in ( + "RUNTIME_TARGET_ENABLED", "BINANCE_DRY_RUN", "BINANCE_RISK_AUTHORITY_JSON", + "BINANCE_RUNTIME_RELEASE_SHA", "BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON", + ): + assert trade_or_runtime_gate not in direct_read + assert "RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}" in direct_read + assert workflow.index("Verify trusted reader checkout and source mode") < ( + workflow.index("Read the bound Spot and Flexible Earn account quantities") + ) < workflow.index("Recheck Runtime source before publishing") < ( + workflow.index("Publish with the isolated account-facts token") + ) + + def test_runtime_workflow_shared_config_shell_contract() -> None: script = ( Path(__file__).resolve().parents[1] @@ -41,7 +144,7 @@ def test_runtime_remote_actions_are_pinned_to_full_commit_shas() -> None: def test_runtime_release_pin_is_required_before_execution_checkout() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert ( "BINANCE_RUNTIME_RELEASE_SHA: ${{ vars.BINANCE_RUNTIME_RELEASE_SHA }}" @@ -64,7 +167,7 @@ def test_runtime_release_pin_is_required_before_execution_checkout() -> None: def test_runtime_workflow_revision_pin_is_required_before_release_selection() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "Verify approved workflow revision" in broker_job assert ( @@ -85,7 +188,7 @@ def test_runtime_workflow_revision_pin_is_required_before_release_selection() -> def test_runtime_workflow_keeps_trigger_sha_distinct_from_app_checkout() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "ref: ${{ github.sha }}" in broker_job assert "ref: ${{ steps.runtime-release.outputs.sha }}" in broker_job @@ -103,7 +206,7 @@ def test_runtime_workflow_keeps_trigger_sha_distinct_from_app_checkout() -> None def test_runtime_dependency_bootstrap_does_not_upgrade_pip_or_uv() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") deps = broker_job.split( " - name: 3. Prepare or update dependency environment", 1 )[1].split(" - name: Download approved accounting migration preview", 1)[0] @@ -158,23 +261,17 @@ def test_candidate_release_sha_guard_rejects_write_modes() -> None: def test_broker_job_cannot_write_repository_contents() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") - log_job = _job_block(workflow, "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" in broker_job - assert "contents: write" not in broker_job + assert "contents: write" not in workflow assert "contents: read" in broker_job - assert "BINANCE_API_KEY" not in log_job - assert "BINANCE_API_SECRET" not in log_job - assert "contents: write" in log_job - assert "actions/download-artifact@" in log_job + assert "publish-execution-log:" not in workflow def test_reconciliation_run_does_not_try_to_publish_a_regular_execution_report() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - log_job = _job_block(workflow, "publish-execution-log") - - assert "github.event.inputs.reconcile_only != 'true'" in log_job + assert "binance-execution-report-" not in workflow def test_reconciliation_artifact_retention_matches_repository_policy() -> None: @@ -195,7 +292,7 @@ def test_reconciliation_failure_still_uploads_the_redacted_candidate() -> None: def test_reconciliation_only_can_collect_evidence_while_normal_runtime_is_disabled() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") reconciliation_gate = "env.RUNTIME_TARGET_ENABLED == 'true' || github.event.inputs.reconcile_only == 'true'" assert broker_job.count(reconciliation_gate) >= 4 @@ -205,7 +302,7 @@ def test_reconciliation_only_can_collect_evidence_while_normal_runtime_is_disabl def test_strategy_step_passes_and_revalidates_runtime_controls_before_main() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") strategy = broker_job.split(" - name: 4. Run trading strategy", 1)[1].split( " - name: Remove protected LIVE risk authority source", 1 )[0] @@ -366,7 +463,7 @@ def test_lifecycle_workflow_is_read_only_and_uses_pinned_actions() -> None: def test_reconciliation_defaults_to_zero_persistence_and_no_notification() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert "reconcile_persist_candidate:" in workflow assert 'default: false' in workflow[workflow.index("reconcile_persist_candidate:") : workflow.index("permissions:")] @@ -377,7 +474,7 @@ def test_reconciliation_defaults_to_zero_persistence_and_no_notification() -> No def test_live_authority_secret_is_materialized_and_removed_in_broker_job() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") materialize = broker_job.split(" - name: Materialize protected LIVE risk authority source", 1)[1].split( " - name:", 1 )[0] @@ -393,7 +490,7 @@ def test_live_authority_secret_is_materialized_and_removed_in_broker_job() -> No assert "if: ${{ always() }}" in cleanup assert ': > "$BINANCE_RISK_AUTHORITY_TEMP_FILE"' in cleanup assert 'rm -- "$BINANCE_RISK_AUTHORITY_TEMP_FILE"' in cleanup - assert "BINANCE_RISK_AUTHORITY_JSON" not in _job_block(workflow, "publish-execution-log") + assert "publish-execution-log:" not in workflow def test_live_authority_materialization_roundtrip_uses_synthetic_secret(tmp_path) -> None: @@ -402,7 +499,7 @@ def test_live_authority_materialization_roundtrip_uses_synthetic_secret(tmp_path import textwrap workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") materialize = textwrap.dedent( broker_job.split(" - name: Materialize protected LIVE risk authority source", 1)[1] .split(" - name:", 1)[0] @@ -434,7 +531,7 @@ def test_live_authority_materialization_roundtrip_uses_synthetic_secret(tmp_path def test_live_lifecycle_recorder_export_is_scoped_to_normal_strategy_runs() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") assert ( "BINANCE_LIFECYCLE_EXPORT_PATH: ${{ runner.temp }}/binance-lifecycle-${{ github.run_id }}-" @@ -450,12 +547,12 @@ def test_live_lifecycle_recorder_export_is_scoped_to_normal_strategy_runs() -> N assert "if-no-files-found: warn" in export_step assert "retention-days: 7" in export_step assert "continue-on-error: true" in export_step - assert "BINANCE_API_KEY" not in _job_block(workflow, "publish-execution-log") + assert "publish-execution-log:" not in workflow def test_disabled_host_observation_uses_actual_control_read_and_existing_source() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") control = broker_job.split("id: runtime-target-control", 1)[1].split(" - name:", 1)[0] assert 'echo "observed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"' in control assert 'if [ "${RUNTIME_TARGET_ENABLED,,}" = "false" ]; then' in control @@ -477,7 +574,7 @@ def test_disabled_host_observation_uses_actual_control_read_and_existing_source( def test_disabled_host_does_not_require_cloud_identity_or_runtime_dependencies() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") identity = broker_job.split(" - name: 0. Validate deployment identity configuration", 1)[1].split(" - name:", 1)[0] assert "if: ${{ env.RUNTIME_TARGET_ENABLED == 'true' || github.event.inputs.reconcile_only == 'true' || github.event.inputs.validate_only == 'true' }}" in identity assert broker_job.index("Publish disabled host observation") < broker_job.index("Authenticate to Google Cloud") @@ -678,7 +775,7 @@ def test_accounting_migration_guard_is_disabled_main_only( def test_accounting_migration_uses_fixed_artifact_and_never_falls_through_to_strategy(): workflow = WORKFLOW.read_text() - broker_job = _job_block(workflow, "deploy", "publish-execution-log") + broker_job = _job_block(workflow, "deploy") download = broker_job.split( " - name: Download approved accounting migration preview", 1 )[1].split(" - name:", 1)[0] diff --git a/tests/test_runtime_workflow_shared_config.sh b/tests/test_runtime_workflow_shared_config.sh index 9663c174..f5413443 100644 --- a/tests/test_runtime_workflow_shared_config.sh +++ b/tests/test_runtime_workflow_shared_config.sh @@ -18,7 +18,10 @@ grep -Fq 'BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA }} grep -Fq 'Verify approved workflow revision' "$workflow_file" grep -Fq 'candidate_release_sha:' "$workflow_file" grep -Fq 'Runtime target is disabled; broker strategy was intentionally not invoked.' "$workflow_file" -grep -Fq 'needs.deploy.outputs.runtime_target_enabled == '\''true'\''' "$workflow_file" +if grep -Fq 'binance-execution-report-' "$workflow_file" || grep -Fq 'publish-execution-log:' "$workflow_file"; then + echo "workflow must not publish complete financial reports to public artifacts or Git" >&2 + exit 1 +fi grep -Fq 'id-token: write' "$workflow_file" grep -Fq 'workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}' "$workflow_file" grep -Fq 'service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$workflow_file"