Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const environment = runtime.env;
const requestKeys = new Set([
"artifact_id",
"instance",
"launchplane_url",
"original_run_attempt",
"original_run_id",
"product",
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/reusable-generic-web-stable-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
15 changes: 11 additions & 4 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion tests/test_generic_web_deploy_recovery_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
Expand All @@ -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) => {{
Expand Down Expand Up @@ -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()
6 changes: 6 additions & 0 deletions tests/test_github_actions_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
Expand Down
Loading