diff --git a/.github/workflows/gcp-iap-operator.yml b/.github/workflows/gcp-iap-operator.yml index c38f434..157063b 100644 --- a/.github/workflows/gcp-iap-operator.yml +++ b/.github/workflows/gcp-iap-operator.yml @@ -179,11 +179,13 @@ jobs: export_environment_variables: true - name: Install Google Cloud CLI + id: setup_gcloud uses: google-github-actions/setup-gcloud@v3 with: project_id: ${{ env.PROJECT_ID }} - name: Run fixed IAP operation + id: operate shell: bash env: OPERATION: ${{ inputs.operation }} @@ -193,6 +195,51 @@ jobs: set -euo pipefail scripts/gcp_iap_operator.sh "${OPERATION}" "${REQUEST_ID}" "${TARGET_DB}" + - name: Ensure sanitized result receipt + if: always() + shell: bash + env: + OPERATION: ${{ inputs.operation }} + REQUEST_ID: ${{ inputs.request_id }} + TARGET_DB: ${{ inputs.target_db }} + VALIDATE_OUTCOME: ${{ steps.validate.outcome }} + AUTH_OUTCOME: ${{ steps.auth.outcome }} + SETUP_GCLOUD_OUTCOME: ${{ steps.setup_gcloud.outcome }} + OPERATE_OUTCOME: ${{ steps.operate.outcome }} + run: | + set -euo pipefail + install -d -m 0700 "${RESULT_DIR}" + result_path="${RESULT_DIR}/result.json" + if [[ -s "${result_path}" ]] && python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${result_path}"; then + exit 0 + fi + RESULT_PATH="${result_path}" python3 - <<'PY' + import json + import os + from pathlib import Path + + result = { + "schema": "livingip.gcpIapOperatorResult.v1", + "operation": os.environ["OPERATION"], + "request_id": os.environ["REQUEST_ID"], + "target_db": os.environ["TARGET_DB"], + "status": "fail", + "failure_class": "workflow_stopped_before_valid_operator_result", + "step_outcomes": { + "validate": os.environ["VALIDATE_OUTCOME"], + "auth": os.environ["AUTH_OUTCOME"], + "setup_gcloud": os.environ["SETUP_GCLOUD_OUTCOME"], + "operate": os.environ["OPERATE_OUTCOME"], + }, + "credential_values_logged": False, + "raw_stdout_retained": False, + "raw_stderr_retained": False, + } + path = Path(os.environ["RESULT_PATH"]) + path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + path.chmod(0o600) + PY + - name: Upload sanitized result if: always() uses: actions/upload-artifact@v4 diff --git a/tests/test_gcp_iap_operator_access.py b/tests/test_gcp_iap_operator_access.py index 9936899..49f488a 100644 --- a/tests/test_gcp_iap_operator_access.py +++ b/tests/test_gcp_iap_operator_access.py @@ -153,6 +153,23 @@ def test_workflow_initializes_runner_paths_at_step_runtime() -> None: assert "runner.temp" not in WORKFLOW.read_text(encoding="utf-8") +def test_workflow_retains_sanitized_failure_receipt_before_upload() -> None: + workflow = load_workflow() + steps = workflow["jobs"]["operate"]["steps"] + receipt_index = next(i for i, step in enumerate(steps) if step.get("name") == "Ensure sanitized result receipt") + upload_index = next(i for i, step in enumerate(steps) if step.get("uses") == "actions/upload-artifact@v4") + receipt = steps[receipt_index] + source = receipt["run"] + assert receipt_index < upload_index + assert receipt["if"] == "always()" + assert receipt["env"]["AUTH_OUTCOME"] == "${{ steps.auth.outcome }}" + assert receipt["env"]["OPERATE_OUTCOME"] == "${{ steps.operate.outcome }}" + assert "workflow_stopped_before_valid_operator_result" in source + assert '"credential_values_logged": False' in source + assert '"raw_stdout_retained": False' in source + assert '"raw_stderr_retained": False' in source + + def test_workflow_builds_exact_main_bundle_from_tracked_files_and_hashes() -> None: source = WORKFLOW.read_text(encoding="utf-8")