From b2055d2944626234664390d6fcd96975ded38511 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sun, 16 Aug 2026 20:39:34 -0400 Subject: [PATCH 1/2] Allow recovery URL in connector envelope --- .../actions/generic-web-deploy-recovery-dry-run/action.yml | 7 +++++-- .../generic-web-deploy-recovery-dry-run/dist/index.mjs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/actions/generic-web-deploy-recovery-dry-run/action.yml b/.github/actions/generic-web-deploy-recovery-dry-run/action.yml index b707e051..dee4f606 100644 --- a/.github/actions/generic-web-deploy-recovery-dry-run/action.yml +++ b/.github/actions/generic-web-deploy-recovery-dry-run/action.yml @@ -4,8 +4,11 @@ description: Inspect one exact legacy generic-web deploy reservation through Lau inputs: launchplane-url: - description: Base URL for the Launchplane service. - required: true + description: >- + Optional base URL for the Launchplane service. When omitted, request-json + must include launchplane_url. + required: false + default: "" request-json: description: Exact legacy deploy coordinates and operator reason as JSON. required: true diff --git a/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs b/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs index f4129e6a..bd8f1173 100644 --- a/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs +++ b/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs @@ -3,6 +3,7 @@ const environment = runtime.env; const requestKeys = new Set([ "artifact_id", "instance", + "launchplane_url", "original_run_attempt", "original_run_id", "product", @@ -68,6 +69,7 @@ function parseRequest(value) { function configureRequestAction() { const request = parseRequest(requiredInput("request-json")); + const launchplaneUrl = input("launchplane-url") || requestString(request, "launchplane_url"); const product = requestString(request, "product"); const instance = requestString(request, "instance"); const artifactId = requestString(request, "artifact_id"); @@ -100,7 +102,7 @@ function configureRequestAction() { reason, }; - environment[environmentKey("launchplane-url")] = requiredInput("launchplane-url"); + environment[environmentKey("launchplane-url")] = launchplaneUrl; environment[environmentKey("route-path")] = "/v1/admin/generic-web/deploy-recovery/dry-run"; environment[environmentKey("payload")] = JSON.stringify(payload); From b095f5a762c401719bc5214b4a8761d30a995772 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sun, 16 Aug 2026 20:41:19 -0400 Subject: [PATCH 2/2] Route recovery through stable deploy identity --- .../reusable-generic-web-stable-deploy.yml | 51 ++++++++++++++++ docs/operations.md | 15 +++-- ...test_generic_web_deploy_recovery_action.py | 58 ++++++++++++++++++- tests/test_github_actions_security.py | 6 ++ 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/.github/workflows/reusable-generic-web-stable-deploy.yml b/.github/workflows/reusable-generic-web-stable-deploy.yml index 2886edb6..d4f5eba3 100644 --- a/.github/workflows/reusable-generic-web-stable-deploy.yml +++ b/.github/workflows/reusable-generic-web-stable-deploy.yml @@ -28,6 +28,13 @@ name: Reusable Generic Web Stable Deploy description: Source git ref attached to deployment evidence. required: true type: string + recovery_request_json: + description: >- + Optional bounded legacy deploy recovery request. When supplied, the + workflow runs only the recovery dry-run job and skips stable deploy. + required: false + default: "" + type: string timeout-ms: description: Launchplane request timeout in milliseconds. required: false @@ -65,6 +72,7 @@ permissions: jobs: stable-deploy: + if: ${{ inputs.recovery_request_json == '' }} outputs: deployment_record_id: ${{ steps.lp.outputs.deployment_record_id }} deploy_status: ${{ steps.lp.outputs.deploy_status }} @@ -151,3 +159,46 @@ jobs: echo "deployment_record_id=$DEPLOYMENT_RECORD_ID" echo "deploy_status=$DEPLOY_STATUS" echo "post_deploy_status=$POST_DEPLOY_STATUS" + + recovery-dry-run: + if: ${{ inputs.recovery_request_json != '' }} + outputs: + recovery_digest: ${{ steps.recovery.outputs.recovery_digest }} + proposed_action: ${{ steps.recovery.outputs.proposed_action }} + reservation_state: ${{ steps.recovery.outputs.reservation_state }} + provider_outcome: ${{ steps.recovery.outputs.provider_outcome }} + provider_status: ${{ steps.recovery.outputs.provider_status }} + retry_safe: ${{ steps.recovery.outputs.retry_safe }} + observed_at: ${{ steps.recovery.outputs.observed_at }} + runs-on: ubuntu-latest + steps: + - 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 }} + timeout-ms: ${{ inputs['timeout-ms'] }} + + - name: Report bounded recovery evidence + env: + RECOVERY_DIGEST: ${{ steps.recovery.outputs.recovery_digest }} + PROPOSED_ACTION: ${{ steps.recovery.outputs.proposed_action }} + RESERVATION_STATE: ${{ steps.recovery.outputs.reservation_state }} + PROVIDER_OUTCOME: ${{ steps.recovery.outputs.provider_outcome }} + PROVIDER_STATUS: ${{ steps.recovery.outputs.provider_status }} + RETRY_SAFE: ${{ steps.recovery.outputs.retry_safe }} + OBSERVED_AT: ${{ steps.recovery.outputs.observed_at }} + run: | + set -euo pipefail + + { + echo "## Generic-web deploy recovery dry run" + echo + echo "- Recovery digest: \`$RECOVERY_DIGEST\`" + echo "- Proposed action: \`$PROPOSED_ACTION\`" + echo "- Reservation state: \`$RESERVATION_STATE\`" + echo "- Provider outcome: \`$PROVIDER_OUTCOME\`" + echo "- Provider status: \`$PROVIDER_STATUS\`" + echo "- Retry safe: \`$RETRY_SAFE\`" + echo "- Observed at: \`$OBSERVED_AT\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/operations.md b/docs/operations.md index 85995c9a..f8d27793 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -401,10 +401,17 @@ Product repositories that need an OIDC-authenticated inspection should use the Launchplane-owned `.github/actions/generic-web-deploy-recovery-dry-run` action. Its single request object accepts only the exact legacy deploy coordinates, original GitHub Actions -run ID and attempt, and operator reason. The action reconstructs the legacy -idempotency key internally, calls only the dry-run route through the shared -request action, suppresses the raw response body, and exposes only the seven -bounded recovery fields documented above. +run ID and attempt, operator reason, and optional connector-only +`launchplane_url`. The action strips the connector URL before constructing the +service payload, reconstructs the legacy idempotency key internally, calls only +the dry-run route through the shared request action, suppresses the raw response +body, and exposes only the seven bounded recovery fields documented above. +Product repositories whose authz grant is bound to the stable-deploy reusable +workflow may pass that request object through the optional +`recovery_request_json` input on +`.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. 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 710e183a..8a26e284 100644 --- a/tests/test_generic_web_deploy_recovery_action.py +++ b/tests/test_generic_web_deploy_recovery_action.py @@ -9,6 +9,7 @@ ACTION_ENTRYPOINT = Path(".github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs") ACTION_METADATA = Path(".github/actions/generic-web-deploy-recovery-dry-run/action.yml") +REUSABLE_WORKFLOW = Path(".github/workflows/reusable-generic-web-stable-deploy.yml") class GenericWebDeployRecoveryActionTests(unittest.TestCase): @@ -17,6 +18,7 @@ def run_action( *, request: dict[str, object], output_path: Path, + launchplane_url: str = "https://launchplane.example", ) -> subprocess.CompletedProcess[str]: if shutil.which("node") is None: self.skipTest("node is required to test the recovery dry-run action") @@ -27,10 +29,11 @@ def run_action( "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.example/token", "GITHUB_OUTPUT": str(output_path), - "INPUT_LAUNCHPLANE-URL": "https://launchplane.example", "INPUT_REQUEST-JSON": json.dumps(request), } ) + if launchplane_url: + env["INPUT_LAUNCHPLANE-URL"] = launchplane_url script = f""" const calls = []; global.fetch = async (url, init) => {{ @@ -167,6 +170,59 @@ def test_action_rejects_unknown_request_fields_before_oidc(self) -> None: calls = json.loads(result.stderr.splitlines()[-1]) self.assertEqual(calls, []) + def test_action_accepts_launchplane_url_from_connector_envelope(self) -> None: + request = { + "schema_version": 1, + "launchplane_url": "https://launchplane.example", + "product": "repairshopr-sync", + "instance": "prod", + "artifact_id": "artifact", + "source_git_ref": "source", + "original_run_id": "29609495343", + "original_run_attempt": "1", + "reason": "Inspect the legacy deploy reservation.", + } + with TemporaryDirectory() as temporary_directory: + result = self.run_action( + request=request, + output_path=Path(temporary_directory) / "github-output.txt", + launchplane_url="", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + calls = json.loads(result.stderr.splitlines()[-1]) + self.assertEqual( + calls[1]["url"], + "https://launchplane.example/v1/admin/generic-web/deploy-recovery/dry-run", + ) + self.assertNotIn("launchplane_url", json.loads(calls[1]["body"])) + + def test_stable_deploy_reusable_workflow_has_dry_run_only_recovery_mode(self) -> None: + workflow = REUSABLE_WORKFLOW.read_text(encoding="utf-8") + + required_fragments = ( + "recovery_request_json:", + "if: ${{ inputs.recovery_request_json == '' }}", + "if: ${{ inputs.recovery_request_json != '' }}", + "name: Request Launchplane recovery dry run", + "uses: cbusillo/launchplane/.github/actions/" + "generic-web-deploy-recovery-dry-run@b2055d2944626234664390d6fcd96975ded38511", + "request-json: ${{ inputs.recovery_request_json }}", + "Recovery digest:", + "Proposed action:", + "Reservation state:", + ) + for fragment in required_fragments: + with self.subTest(fragment=fragment): + self.assertIn(fragment, workflow) + + self.assertNotIn("generic-web/deploy-recovery/apply", workflow) + self.assertNotIn("expected_recovery_digest", workflow) + self.assertNotIn( + "launchplane-url: >-\n ${{ inputs.launchplane_url || vars.LAUNCHPLANE_PUBLIC_URL }}", + workflow.split(" recovery-dry-run:", maxsplit=1)[1], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_github_actions_security.py b/tests/test_github_actions_security.py index 8b62e2d4..3232cf2e 100644 --- a/tests/test_github_actions_security.py +++ b/tests/test_github_actions_security.py @@ -162,6 +162,12 @@ def location(self) -> str: "cbusillo/launchplane/.github/actions/launchplane-request": ActionClassification( "First-party cross-repository", "OIDC-authenticated Launchplane API requests" ), + "cbusillo/launchplane/.github/actions/generic-web-deploy-recovery-dry-run": ( + ActionClassification( + "First-party cross-repository", + "bounded legacy generic-web deploy reservation inspection", + ) + ), "cbusillo/launchplane/.github/actions/setup-odoo-preview-request-client": ( ActionClassification("First-party cross-repository", "preview request client setup") ),