From 7d3202509c57fcaf3c1fd933b6085d36737f525f Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sun, 16 Aug 2026 21:15:29 -0400 Subject: [PATCH] Route recovery dry runs through reusable workflow identity --- .../reusable-generic-web-stable-deploy.yml | 76 ++++++++++++++++++- docs/operations.md | 6 ++ ...test_generic_web_deploy_recovery_action.py | 10 ++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.github/workflows/reusable-generic-web-stable-deploy.yml b/.github/workflows/reusable-generic-web-stable-deploy.yml index d4f5eba37..5eb4ceb4f 100644 --- a/.github/workflows/reusable-generic-web-stable-deploy.yml +++ b/.github/workflows/reusable-generic-web-stable-deploy.yml @@ -72,7 +72,9 @@ permissions: jobs: stable-deploy: - if: ${{ inputs.recovery_request_json == '' }} + if: >- + inputs.recovery_request_json == '' && + (github.event_name != 'workflow_dispatch' || github.event.inputs.original_run_id == '') outputs: deployment_record_id: ${{ steps.lp.outputs.deployment_record_id }} deploy_status: ${{ steps.lp.outputs.deploy_status }} @@ -161,7 +163,9 @@ jobs: echo "post_deploy_status=$POST_DEPLOY_STATUS" recovery-dry-run: - if: ${{ inputs.recovery_request_json != '' }} + if: >- + inputs.recovery_request_json != '' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.original_run_id != '') outputs: recovery_digest: ${{ steps.recovery.outputs.recovery_digest }} proposed_action: ${{ steps.recovery.outputs.proposed_action }} @@ -172,11 +176,77 @@ jobs: observed_at: ${{ steps.recovery.outputs.observed_at }} runs-on: ubuntu-latest steps: + - name: Resolve Launchplane recovery request + id: request + env: + ARTIFACT_ID: ${{ inputs.artifact_id }} + EXPLICIT_REQUEST: ${{ inputs.recovery_request_json }} + INSTANCE: ${{ inputs.instance }} + ORIGINAL_RUN_ATTEMPT: ${{ github.event.inputs.original_run_attempt }} + ORIGINAL_RUN_ID: ${{ github.event.inputs.original_run_id }} + PRODUCT: ${{ inputs.product }} + REASON: ${{ github.event.inputs.reason }} + SOURCE_GIT_REF: ${{ inputs.source_git_ref }} + run: | + set -euo pipefail + + if [ -n "$EXPLICIT_REQUEST" ]; then + request="$EXPLICIT_REQUEST" + else + if [ -z "$PRODUCT" ]; then + PRODUCT="${GITHUB_REPOSITORY#*/}" + fi + if [ -z "$INSTANCE" ]; then + INSTANCE="testing" + fi + for required in \ + PRODUCT \ + INSTANCE \ + ARTIFACT_ID \ + SOURCE_GIT_REF \ + ORIGINAL_RUN_ID \ + ORIGINAL_RUN_ATTEMPT \ + REASON; do + if [ -z "${!required}" ]; then + echo "${required} is required for recovery dry-run." >&2 + exit 1 + fi + done + + launchplane_url_json='${{ toJSON(inputs.launchplane_url || vars.LAUNCHPLANE_PUBLIC_URL) }}' + request="$(jq -cn \ + --argjson launchplane_url "$launchplane_url_json" \ + --arg product "$PRODUCT" \ + --arg instance "$INSTANCE" \ + --arg artifact_id "$ARTIFACT_ID" \ + --arg source_git_ref "$SOURCE_GIT_REF" \ + --arg original_run_id "$ORIGINAL_RUN_ID" \ + --arg original_run_attempt "$ORIGINAL_RUN_ATTEMPT" \ + --arg reason "$REASON" \ + '{ + schema_version: 1, + launchplane_url: $launchplane_url, + product: $product, + instance: $instance, + artifact_id: $artifact_id, + source_git_ref: $source_git_ref, + original_run_id: $original_run_id, + original_run_attempt: $original_run_attempt, + reason: $reason + }')" + fi + + { + echo "request<> "$GITHUB_OUTPUT" + - name: Request Launchplane recovery dry run id: recovery uses: cbusillo/launchplane/.github/actions/generic-web-deploy-recovery-dry-run@b2055d2944626234664390d6fcd96975ded38511 # main with: - request-json: ${{ inputs.recovery_request_json }} + request-json: ${{ steps.request.outputs.request }} timeout-ms: ${{ inputs['timeout-ms'] }} - name: Report bounded recovery evidence diff --git a/docs/operations.md b/docs/operations.md index f8d277938..09bf46e41 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -412,6 +412,12 @@ workflow may pass that request object through the optional `.github/workflows/reusable-generic-web-stable-deploy.yml`. A non-empty recovery request skips the stable-deploy job and runs only the bounded dry-run action; the reusable workflow exposes no recovery apply input or route. +For existing product connectors that must preserve an established reusable-job +identity, the workflow also recognizes a caller `workflow_dispatch` event with +non-empty `original_run_id`, `original_run_attempt`, and `reason` inputs. It +constructs the same private recovery envelope from the caller event plus the +existing product, instance, artifact, source, and Launchplane URL inputs, then +skips stable deploy exactly as the explicit envelope path does. Stage 2 apply is explicit and digest-gated. Operators call `POST /v1/admin/generic-web/deploy-recovery/apply` with the same request body as diff --git a/tests/test_generic_web_deploy_recovery_action.py b/tests/test_generic_web_deploy_recovery_action.py index 8a26e2845..30ebd408d 100644 --- a/tests/test_generic_web_deploy_recovery_action.py +++ b/tests/test_generic_web_deploy_recovery_action.py @@ -202,12 +202,16 @@ def test_stable_deploy_reusable_workflow_has_dry_run_only_recovery_mode(self) -> required_fragments = ( "recovery_request_json:", - "if: ${{ inputs.recovery_request_json == '' }}", - "if: ${{ inputs.recovery_request_json != '' }}", + "github.event.inputs.original_run_id == ''", + "github.event.inputs.original_run_id != ''", + "name: Resolve Launchplane recovery request", + "ORIGINAL_RUN_ATTEMPT: ${{ github.event.inputs.original_run_attempt }}", + "ORIGINAL_RUN_ID: ${{ github.event.inputs.original_run_id }}", + "REASON: ${{ github.event.inputs.reason }}", "name: Request Launchplane recovery dry run", "uses: cbusillo/launchplane/.github/actions/" "generic-web-deploy-recovery-dry-run@b2055d2944626234664390d6fcd96975ded38511", - "request-json: ${{ inputs.recovery_request_json }}", + "request-json: ${{ steps.request.outputs.request }}", "Recovery digest:", "Proposed action:", "Reservation state:",