diff --git a/.github/workflows/sync-cloud-run-env-protected.yml b/.github/workflows/sync-cloud-run-env-protected.yml index 32776916..b2c17e13 100644 --- a/.github/workflows/sync-cloud-run-env-protected.yml +++ b/.github/workflows/sync-cloud-run-env-protected.yml @@ -5,6 +5,9 @@ name: Deploy Cloud Run (protected execution) on: workflow_call: inputs: + dry_run: + type: boolean + required: true notification_marker_mode: type: string required: false @@ -141,8 +144,9 @@ env: jobs: sync-cloud-run-env: name: Deploy / Sync Cloud Run + environment: ${{ vars.IBKR_DEPLOY_ENVIRONMENT }} # Production deployment and environment sync require an explicit operator dispatch. - if: github.event_name == 'workflow_dispatch' && inputs.notification_only != true && inputs.notification_readback_only != true && (inputs.notification_marker_mode == '' || inputs.notification_marker_mode == 'disabled') + if: github.event_name == 'workflow_dispatch' && inputs.notification_only != true && inputs.notification_readback_only != true && (inputs.notification_marker_mode == '' || inputs.notification_marker_mode == 'disabled') || (github.event_name == 'workflow_dispatch' && inputs.dry_run == true) runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -150,6 +154,15 @@ jobs: contents: read id-token: write env: + GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ secrets.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + GCP_RUNTIME_SERVICE_ACCOUNT: ${{ secrets.IBKR_RUNTIME_SERVICE_ACCOUNT }} + GCP_SCHEDULER_SERVICE_ACCOUNT: ${{ secrets.IBKR_SCHEDULER_SERVICE_ACCOUNT }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + INPUT_NOTIFICATION_ONLY: ${{ inputs.notification_only }} + INPUT_NOTIFICATION_READBACK_ONLY: ${{ inputs.notification_readback_only }} + INPUT_NOTIFICATION_MARKER_MODE: ${{ inputs.notification_marker_mode }} ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} @@ -293,10 +306,34 @@ jobs: fi done + - name: Validate protected deployment environment + env: + DEPLOY_ENVIRONMENT: ${{ vars.IBKR_DEPLOY_ENVIRONMENT }} + run: | + set -euo pipefail + if [ -z "${DEPLOY_ENVIRONMENT:-}" ]; then + echo 'Protected deployment Environment selector is required.' >&2 + exit 1 + fi + - name: Check whether Cloud Run automation is enabled id: config run: | set -euo pipefail + case "${INPUT_DRY_RUN-false}" in + true|false) ;; + *) echo 'Invalid deployment dry_run input.' >&2; exit 1 ;; + esac + if [ "${INPUT_DRY_RUN-false}" = true ] && + { [ "${INPUT_NOTIFICATION_ONLY:-false}" = true ] || + [ "${INPUT_NOTIFICATION_READBACK_ONLY:-false}" = true ] || + { [ "${INPUT_NOTIFICATION_MARKER_MODE:-disabled}" != disabled ] && [ -n "${INPUT_NOTIFICATION_MARKER_MODE:-}" ]; } || + [ "${INPUT_RUNTIME_SWITCH_ONLY:-false}" = true ] || + [ -n "${INPUT_INITIAL_STATE_SOURCE_RUN_ID:-}" ]; }; then + echo 'Deployment dry-run does not support notification or runtime recovery modes.' >&2 + exit 1 + fi + write_github_output() { printf '%s\n' "$@" >> "$GITHUB_OUTPUT" @@ -395,6 +432,10 @@ jobs: "scheduler_sync_enabled=${scheduler_sync_enabled}" if [ "${deploy_enabled}" != "true" ] && [ "${env_sync_enabled}" != "true" ] && [ "${traffic_shift_enabled}" != "true" ] && [ "${scheduler_sync_enabled}" != "true" ]; then + if [ "${INPUT_DRY_RUN-false}" = true ]; then + echo 'Dry-run requires at least one enabled deployment control.' >&2 + exit 1 + fi write_github_output "enabled=false" echo "Skipping Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true." >&2 exit 0 @@ -750,8 +791,26 @@ jobs: exit 1 fi + - name: Validate Cloud Scheduler inputs + if: steps.config.outputs.scheduler_sync_enabled == 'true' && inputs.runtime_switch_only != true + run: | + set -euo pipefail + scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION:-}}" + if [ -z "${scheduler_location}" ]; then + echo "Cloud Scheduler schedule sync requires CLOUD_RUN_REGION or CLOUD_SCHEDULER_LOCATION." >&2 + exit 1 + fi + if [ -z "${GCP_SCHEDULER_SERVICE_ACCOUNT:-}" ]; then + echo "Cloud Scheduler schedule sync requires GCP_SCHEDULER_SERVICE_ACCOUNT." >&2 + exit 1 + fi + + - name: Confirm deployment dry-run validation + if: inputs.dry_run == true && steps.config.outputs.enabled == 'true' + run: echo 'Deployment plan validated; build, cloud writes, traffic and Scheduler operations were not executed.' + - name: Capture no-traffic deployment baseline - if: steps.config.outputs.deploy_enabled == 'true' + if: inputs.dry_run == false && (steps.config.outputs.deploy_enabled == 'true') env: DEPLOY_READBACK_DIR: ${{ runner.temp }}/cloud-run-no-traffic-baseline run: | @@ -780,7 +839,7 @@ jobs: done - name: Verify deployed runtime target admission before traffic shift - if: steps.config.outputs.deploy_enabled == 'true' + if: inputs.dry_run == false && (steps.config.outputs.deploy_enabled == 'true') run: | set -euo pipefail if [ -n "${IBKR_INITIAL_SYNC_PLAN_PATH:-}" ]; then export SYNC_PLAN_JSON="$(cat "$IBKR_INITIAL_SYNC_PLAN_PATH")"; fi @@ -793,7 +852,7 @@ jobs: env: WARMUP_TRAFFIC_APPROVED: ${{ steps.config.outputs.traffic_shift_enabled }} WARMUP_ENV_SYNC: ${{ steps.config.outputs.env_sync_enabled }} - if: steps.config.outputs.deploy_enabled == 'true' + if: inputs.dry_run == false && (steps.config.outputs.deploy_enabled == 'true') run: | set -euo pipefail if [ -n "${IBKR_INITIAL_SYNC_PLAN_PATH:-}" ]; then export SYNC_PLAN_JSON="$(cat "$IBKR_INITIAL_SYNC_PLAN_PATH")"; fi @@ -921,7 +980,7 @@ jobs: done - name: Verify no-traffic candidate startup health - if: steps.config.outputs.deploy_enabled == 'true' && steps.deploy.outputs.warmup_managed != 'true' + if: inputs.dry_run == false && (steps.config.outputs.deploy_enabled == 'true' && steps.deploy.outputs.warmup_managed != 'true') env: EXPECTED_IMAGE_DIGEST: ${{ steps.deploy.outputs.image_digest }} run: | @@ -937,12 +996,12 @@ jobs: - name: Summarize build-time UES binding coverage continue-on-error: true - if: always() && steps.config.outputs.deploy_enabled == 'true' + if: always() && steps.config.outputs.deploy_enabled == 'true' && inputs.dry_run == false run: | python3 scripts/verify_image_ues_release_binding.py --summarize-report "${RUNNER_TEMP}/ues-binding-coverage.json" >> "${GITHUB_STEP_SUMMARY}" - name: Verify no-traffic deployment readback - if: steps.config.outputs.deploy_enabled == 'true' && steps.deploy.outputs.warmup_managed != 'true' + if: inputs.dry_run == false && (steps.config.outputs.deploy_enabled == 'true' && steps.deploy.outputs.warmup_managed != 'true') env: DEPLOY_READBACK_DIR: ${{ runner.temp }}/cloud-run-no-traffic-baseline EXPECTED_IMAGE_DIGEST: ${{ steps.deploy.outputs.image_digest }} @@ -969,7 +1028,7 @@ jobs: done - name: Wait for Cloud Run deployment of current commit - if: steps.config.outputs.deploy_enabled == 'true' && steps.config.outputs.env_sync_enabled == 'true' + if: inputs.dry_run == false && (steps.config.outputs.deploy_enabled == 'true' && steps.config.outputs.env_sync_enabled == 'true') run: | set -euo pipefail if [ -n "${IBKR_INITIAL_SYNC_PLAN_PATH:-}" ]; then export SYNC_PLAN_JSON="$(cat "$IBKR_INITIAL_SYNC_PLAN_PATH")"; fi @@ -1017,7 +1076,7 @@ jobs: done - name: Prepare env-only revision and runtime switch - if: steps.config.outputs.env_sync_enabled == 'true' && steps.config.outputs.deploy_enabled != 'true' && steps.config.outputs.traffic_shift_enabled == 'true' + if: inputs.dry_run == false && (steps.config.outputs.env_sync_enabled == 'true' && steps.config.outputs.deploy_enabled != 'true' && steps.config.outputs.traffic_shift_enabled == 'true') env: EXACT_ENV_TRAFFIC: ${{ steps.config.outputs.deploy_enabled != 'true' && steps.config.outputs.traffic_shift_enabled == 'true' }} RUNTIME_SWITCH_ONLY: ${{ inputs.runtime_switch_only }} @@ -1233,7 +1292,7 @@ jobs: fi - name: Sync Cloud Run environment - if: steps.config.outputs.env_sync_enabled == 'true' && inputs.runtime_switch_only != true && inputs.initial_state_source_run_id == '' + if: inputs.dry_run == false && (steps.config.outputs.env_sync_enabled == 'true' && inputs.runtime_switch_only != true && inputs.initial_state_source_run_id == '') env: EXACT_ENV_TRAFFIC: ${{ steps.config.outputs.deploy_enabled != 'true' && steps.config.outputs.traffic_shift_enabled == 'true' }} run: | @@ -1624,7 +1683,7 @@ jobs: done - name: Reconcile Cloud Run traffic - if: steps.config.outputs.traffic_shift_enabled == 'true' && steps.deploy.outputs.warmup_managed != 'true' + if: inputs.dry_run == false && (steps.config.outputs.traffic_shift_enabled == 'true' && steps.deploy.outputs.warmup_managed != 'true') run: | set -euo pipefail if [ -n "${IBKR_INITIAL_SYNC_PLAN_PATH:-}" ]; then export SYNC_PLAN_JSON="$(cat "$IBKR_INITIAL_SYNC_PLAN_PATH")"; fi @@ -1665,7 +1724,7 @@ jobs: fi - name: Verify strategy plugin mounts - if: steps.config.outputs.env_sync_enabled == 'true' && inputs.runtime_switch_only != true && inputs.initial_state_source_run_id == '' + if: inputs.dry_run == false && (steps.config.outputs.env_sync_enabled == 'true' && inputs.runtime_switch_only != true && inputs.initial_state_source_run_id == '') env: STRATEGY_PLUGIN_MOUNT_ENV_NAMES: IBKR_STRATEGY_PLUGIN_MOUNTS_JSON run: | @@ -1674,7 +1733,7 @@ jobs: python3 scripts/verify_cloud_run_strategy_plugin_mounts.py - name: Reconcile existing runtime switch scheduler - if: steps.config.outputs.scheduler_sync_enabled == 'true' && inputs.runtime_switch_only == true + if: inputs.dry_run == false && (steps.config.outputs.scheduler_sync_enabled == 'true' && inputs.runtime_switch_only == true) run: | set -euo pipefail if [ -n "${IBKR_INITIAL_SYNC_PLAN_PATH:-}" ]; then export SYNC_PLAN_JSON="$(cat "$IBKR_INITIAL_SYNC_PLAN_PATH")"; fi @@ -1742,7 +1801,7 @@ jobs: PY - name: Save runtime switch result - if: always() && inputs.runtime_switch_only == true + if: inputs.dry_run == false && (always() && inputs.runtime_switch_only == true) uses: actions/upload-artifact@v4 with: name: ibkr-runtime-switch-result-${{ github.run_id }}-${{ github.run_attempt }} @@ -1751,20 +1810,12 @@ jobs: retention-days: 7 - name: Sync Cloud Scheduler schedule - if: steps.config.outputs.scheduler_sync_enabled == 'true' && inputs.runtime_switch_only != true + if: inputs.dry_run == false && (steps.config.outputs.scheduler_sync_enabled == 'true' && inputs.runtime_switch_only != true) run: | set -euo pipefail if [ -n "${IBKR_INITIAL_SYNC_PLAN_PATH:-}" ]; then export SYNC_PLAN_JSON="$(cat "$IBKR_INITIAL_SYNC_PLAN_PATH")"; fi - scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" - if [ -z "${scheduler_location}" ]; then - echo "Cloud Scheduler schedule sync requires CLOUD_RUN_REGION or CLOUD_SCHEDULER_LOCATION." >&2 - exit 1 - fi - if [ -z "${GCP_SCHEDULER_SERVICE_ACCOUNT:-}" ]; then - echo "Cloud Scheduler schedule sync requires GCP_SCHEDULER_SERVICE_ACCOUNT." >&2 - exit 1 - fi + scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION:-}}" mapfile -t scheduler_updates < <(python - <<'PY' import json @@ -2102,7 +2153,7 @@ jobs: notification-only: name: Fixed notification overlay / no traffic - if: github.event_name == 'workflow_dispatch' && inputs.notification_only == true && (inputs.notification_marker_mode == '' || inputs.notification_marker_mode == 'disabled') + if: github.event_name == 'workflow_dispatch' && inputs.notification_only == true && inputs.dry_run == false && (inputs.notification_marker_mode == '' || inputs.notification_marker_mode == 'disabled') runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -2214,7 +2265,7 @@ jobs: notification-binding-prepare: name: Prepare exact IBKR notification binding / read only - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.notification_marker_mode == 'binding-prepare' + if: inputs.dry_run == false && (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.notification_marker_mode == 'binding-prepare') runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -2312,7 +2363,7 @@ jobs: notification-binding-read: name: Read one exact IBKR business binding only - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.notification_marker_mode == 'binding-read' + if: inputs.dry_run == false && (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.notification_marker_mode == 'binding-read') runs-on: ubuntu-latest timeout-minutes: 3 permissions: @@ -2364,7 +2415,7 @@ jobs: notification-marker-probe: name: Fixed synthetic marker Job control - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && ((inputs.notification_marker_mode == 'execute-probe' && inputs.approve_marker_probe == true) || ((inputs.notification_marker_mode == 'probe-readback' || inputs.notification_marker_mode == 'probe-validate-only') && inputs.approve_marker_probe == false)) + if: inputs.dry_run == false && (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && ((inputs.notification_marker_mode == 'execute-probe' && inputs.approve_marker_probe == true) || ((inputs.notification_marker_mode == 'probe-readback' || inputs.notification_marker_mode == 'probe-validate-only') && inputs.approve_marker_probe == false))) runs-on: ubuntu-latest timeout-minutes: 6 permissions: diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index e575cc2b..16d7eac1 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -4,6 +4,11 @@ run-name: ${{ inputs.runtime_switch_only && format('Runtime switch {0}', inputs. on: workflow_dispatch: inputs: + dry_run: + description: "Validate the selected deployment plan without build, cloud writes, traffic or Scheduler changes; false requires separate execution approval." + required: true + type: boolean + default: true notification_marker_mode: description: "Isolated notification binding read/prepare or retained synthetic marker Job; deployment controls stay false." required: true @@ -129,6 +134,7 @@ jobs: id-token: write uses: ./.github/workflows/sync-cloud-run-env-protected.yml with: + dry_run: ${{ inputs.dry_run }} notification_marker_mode: ${{ inputs.notification_marker_mode }} approve_marker_probe: ${{ inputs.approve_marker_probe }} notification_only: ${{ inputs.notification_only }} diff --git a/docs/ibkr-runtime-rollout.md b/docs/ibkr-runtime-rollout.md index 058ba5d1..c93b88cd 100644 --- a/docs/ibkr-runtime-rollout.md +++ b/docs/ibkr-runtime-rollout.md @@ -464,3 +464,24 @@ Official references: [Minimum instances](https://cloud.google.com/run/docs/confi [service update flags](https://cloud.google.com/sdk/gcloud/reference/run/services/update), [traffic tags](https://cloud.google.com/sdk/gcloud/reference/run/services/update-traffic), and [revision conditions](https://cloud.google.com/run/docs/reference/rest/v2/Condition). + +### Protected deployment dry-run + +The manual deploy workflow defaults `dry_run` to `true`. The deployment job +selects its GitHub Environment through the repository variable +`IBKR_DEPLOY_ENVIRONMENT`, resolving its WIF credentials from Environment Secrets. +The selector must name an existing owner-protected Environment; an empty selector +fails before authentication. Set it through a separately authorized configuration +change after confirming approval and branch restrictions. Existing Environment approval and +branch restrictions must be configured independently; this workflow does not +create or modify them. + +Select one exact configured service and the intended deployment controls. A +dry-run authenticates, resolves the admitted target and validates the deployment +inputs, but skips image builds, revision preparation, cloud writes, traffic, +Scheduler operations and runtime-switch result persistence. It is plan validation, +not evidence that a cloud write will succeed or that a serving revision adopted +the candidate. Missing controls or invalid targets fail validation. Notification, +runtime-switch and initial-state recovery modes require explicit `dry_run=false`. +Only a separately authorized execution may set `dry_run=false`; all existing +main-ref, admission and approval gates still apply. diff --git a/tests/test_deploy_dry_run_workflow.py b/tests/test_deploy_dry_run_workflow.py new file mode 100644 index 00000000..d84cfb12 --- /dev/null +++ b/tests/test_deploy_dry_run_workflow.py @@ -0,0 +1,92 @@ +"""Offline adverse checks for the deploy dry-run boundary.""" +import os +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/sync-cloud-run-env-protected.yml" + + +def _section(name): + return WORKFLOW.read_text().split(" - name: " + name + "\n", 1)[1].split("\n - name:", 1)[0] + + +def test_dry_run_default_and_forwarding(): + caller = (ROOT / ".github/workflows/sync-cloud-run-env.yml").read_text() + assert " dry_run:\n" in caller + assert "dry_run: ${{ inputs.dry_run }}" in caller + assert " dry_run:\n type: boolean\n required: true" in WORKFLOW.read_text() + + +def test_environment_credentials_are_job_bound(): + text = WORKFLOW.read_text() + job = text.split(" sync-cloud-run-env:\n", 1)[1].split(" steps:", 1)[0] + assert "environment: ${{ vars.IBKR_DEPLOY_ENVIRONMENT }}" in job + for name in ("GCP_PROJECT_ID", "GCP_WORKLOAD_IDENTITY_PROVIDER", "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT"): + assert name + ": ${{ secrets." + name + " }}" in job + + +def test_every_post_validation_step_requires_explicit_execution(): + text = WORKFLOW.read_text().split(" - name: Capture no-traffic deployment baseline", 1)[1].split("\n notification-only:", 1)[0] + sections = [text.split("\n - name:", 1)[0]] + text.split("\n - name:")[1:] + assert len(sections) >= 10 + for section in sections: + condition = next(line.strip() for line in section.splitlines() if line.strip().startswith("if:")) + assert "inputs.dry_run == false" in condition + + +def test_invalid_dry_run_fails_before_emitting_enablement(tmp_path): + section = _section("Check whether Cloud Run automation is enabled") + body = section.split(" run: |\n", 1)[1] + script = "\n".join(line[10:] for line in body.splitlines()) + for value in ("unknown", "", "TRUE"): + output = tmp_path / ("output-" + value) + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], env=dict(os.environ, INPUT_DRY_RUN=value, GITHUB_EVENT_NAME="workflow_dispatch", ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION="false", GITHUB_OUTPUT=str(output)), capture_output=True, text=True) + assert result.returncode != 0 + assert not output.exists() + + +def test_dry_run_preserves_intended_controls_and_rejects_other_modes(tmp_path): + body = _section("Check whether Cloud Run automation is enabled").split(" run: |\n", 1)[1] + script = "\n".join(line[10:] for line in body.splitlines()) + common = dict(os.environ, INPUT_DRY_RUN="true", GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF="refs/heads/main", ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION="false", ENABLE_GITHUB_CLOUD_RUN_DEPLOY="true", INPUT_DEPLOY_IMAGE="true", INPUT_SYNC_ENV="false", ENABLE_GITHUB_ENV_SYNC="false", INPUT_APPROVE_TRAFFIC_SHIFT="false", INPUT_APPROVE_SCHEDULER_SYNC="false", INPUT_CONFIGURED_SERVICE="fake-paper-service", INPUT_SOURCE_COMMIT="a" * 40) + output = tmp_path / "valid" + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], env=dict(common, GITHUB_OUTPUT=str(output)), capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "deploy_enabled=true" in output.read_text() + for extra in ({"INPUT_NOTIFICATION_ONLY": "true"}, {"INPUT_RUNTIME_SWITCH_ONLY": "true"}, {"ENABLE_GITHUB_CLOUD_RUN_DEPLOY": "false"}): + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], env=dict(common, **extra, GITHUB_OUTPUT=str(tmp_path / "invalid")), capture_output=True, text=True) + assert result.returncode != 0 + + +def test_missing_environment_selector_blocks_before_authentication(tmp_path): + body = _section("Validate protected deployment environment").split(" run: |\n", 1)[1] + script = "\n".join(line[10:] for line in body.splitlines()) + for value, expected in (("", 1), ("fake-protected-environment", 0)): + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], env=dict(os.environ, DEPLOY_ENVIRONMENT=value), capture_output=True, text=True) + assert result.returncode == expected + assert value not in result.stderr if value else True + + +def test_scheduler_only_dry_run_validates_required_inputs(): + text = WORKFLOW.read_text().split(" - name: Confirm deployment dry-run validation", 1)[0] + sections = [section for section in text.split(" - name: ") + if "if: steps.config.outputs.scheduler_sync_enabled == 'true'" in section] + assert len(sections) == 1 + body = sections[0].split(" run: |\n", 1)[1] + script = "\n".join(line[10:] for line in body.splitlines()) + common = {key: value for key, value in os.environ.items() + if key not in {"CLOUD_RUN_REGION", "CLOUD_SCHEDULER_LOCATION", "GCP_SCHEDULER_SERVICE_ACCOUNT"}} + cases = [({}, False), + ({"CLOUD_RUN_REGION": "", "CLOUD_SCHEDULER_LOCATION": ""}, False), + ({"CLOUD_RUN_REGION": "fake-region"}, False), + ({"GCP_SCHEDULER_SERVICE_ACCOUNT": "fake-scheduler"}, False), + ({"CLOUD_RUN_REGION": "fake-region", "GCP_SCHEDULER_SERVICE_ACCOUNT": "fake-scheduler"}, True), + ({"CLOUD_SCHEDULER_LOCATION": "fake-location", "GCP_SCHEDULER_SERVICE_ACCOUNT": "fake-scheduler"}, True)] + for extra, valid in cases: + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + env=dict(common, **extra), capture_output=True, text=True) + assert (result.returncode == 0) == valid + for value in extra.values(): + if value: + assert value not in result.stderr diff --git a/tests/test_env_sync_drift_check.py b/tests/test_env_sync_drift_check.py index 28369329..7f63a982 100644 --- a/tests/test_env_sync_drift_check.py +++ b/tests/test_env_sync_drift_check.py @@ -42,6 +42,17 @@ def test_drift_workflow_env_mirrors_sync_workflow(): assert sync_env.pop("INPUT_INITIAL_STATE_SOURCE_RUN_ID") == "''" assert sync_env.pop("INPUT_INITIAL_STATE_CURRENT_RUN_ID") == "''" assert sync_env.pop("IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PREFIX") == "${{ secrets.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PREFIX }}" + # Deployment admission and Environment credentials are intentionally job-scoped. + for key in ("INPUT_DRY_RUN", "INPUT_NOTIFICATION_ONLY", "INPUT_NOTIFICATION_READBACK_ONLY", "INPUT_NOTIFICATION_MARKER_MODE"): + assert sync_env.pop(key) == "''" + for key, secret in ( + ("GCP_PROJECT_ID", "GCP_PROJECT_ID"), + ("GCP_WORKLOAD_IDENTITY_PROVIDER", "GCP_WORKLOAD_IDENTITY_PROVIDER"), + ("GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT", "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT"), + ("GCP_RUNTIME_SERVICE_ACCOUNT", "IBKR_RUNTIME_SERVICE_ACCOUNT"), + ("GCP_SCHEDULER_SERVICE_ACCOUNT", "IBKR_SCHEDULER_SERVICE_ACCOUNT"), + ): + assert sync_env.pop(key) == "${{ secrets." + secret + " }}" assert drift_env == sync_env