From 6bac61a1967c6adce8bdfd32cfbcdae362134a34 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sun, 27 Sep 2026 15:43:36 -0400 Subject: [PATCH 1/2] Rebuild the current stable-deploy key in deploy recovery Recovery rebuilt the pre-2026-08-05 idempotency key and payload, so every reservation made by the current reusable stable deploy came back as reservation_not_found. The action now builds the artifact-scoped key and sends deploy_reference like the original deploy did. The run-scoped key stays available by explicit deploy_key_format. A provider-evidence mode lets the reusable workflow reuse this one key builder. Refs #2531 --- .../action.yml | 19 +- .../dist/index.mjs | 121 +++++++++--- ...test_generic_web_deploy_recovery_action.py | 179 ++++++++++++++++++ 3 files changed, 284 insertions(+), 35 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 ab9421f3f..1120445d2 100644 --- a/.github/actions/generic-web-deploy-recovery-dry-run/action.yml +++ b/.github/actions/generic-web-deploy-recovery-dry-run/action.yml @@ -1,6 +1,6 @@ --- name: Generic-web deploy recovery -description: Inspect or apply one exact legacy generic-web deploy recovery through Launchplane. +description: Inspect or apply one exact generic-web deploy recovery through Launchplane. inputs: launchplane-url: @@ -11,9 +11,16 @@ inputs: default: "" request-json: description: >- - Optional exact legacy deploy coordinates and operator reason as JSON for - dry-run mode only. When omitted, the action downloads and validates the - approved digest-bound apply artifact for the current workflow_run event. + Optional exact original deploy coordinates and operator reason as JSON + for dry-run or provider-evidence mode only. When omitted, the action + downloads and validates the approved digest-bound apply artifact for the + current workflow_run event. + required: false + default: "" + mode: + description: >- + Empty for dry-run or digest-bound apply; provider-evidence for the + advisory exact provider evidence read. required: false default: "" expected-product: @@ -66,6 +73,10 @@ outputs: description: Whether retrying the original operation is safe. observed_at: description: Timestamp for the dry-run observation. + provider_evidence: + description: Bounded provider evidence classification in provider-evidence mode. + provider_read_error_class: + description: Bounded provider read error class in provider-evidence mode. runs: using: node24 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 640138896..bbb130d30 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 @@ -17,18 +17,16 @@ const artifactRequestKeys = new Set([ "schema_version", "source_git_ref", ]); +const optionalIdentityKeys = new Set(["deploy_key_format", "deploy_reference"]); const requestKeys = new Set([ - "artifact_id", - "expected_recovery_digest", - "instance", + ...artifactRequestKeys, + ...optionalIdentityKeys, "launchplane_url", - "original_run_attempt", - "original_run_id", - "product", - "reason", - "schema_version", - "source_git_ref", ]); +// Idempotency-key layouts written by reusable-generic-web-stable-deploy.yml. +// "artifact_scoped" is the current layout; "run_scoped" predates 2026-08-05. +const deployKeyFormats = new Set(["artifact_scoped", "run_scoped"]); +const recoveryModes = new Set(["", "provider-evidence"]); function environmentKey(name) { return `INPUT_${name.replaceAll(" ", "_").toUpperCase()}`; @@ -159,9 +157,11 @@ async function loadRequest() { } const request = parseRequest(readFileSync(requestFile, "utf8")); - const requestKeyList = Object.keys(request).sort(); - const expectedKeyList = [...artifactRequestKeys].sort(); - if (JSON.stringify(requestKeyList) !== JSON.stringify(expectedKeyList)) { + const requestKeyList = Object.keys(request); + if ( + [...artifactRequestKeys].some(key => !requestKeyList.includes(key)) || + requestKeyList.some(key => !artifactRequestKeys.has(key) && !optionalIdentityKeys.has(key)) + ) { throw new Error("Recovery apply artifact has an invalid schema."); } @@ -271,23 +271,80 @@ async function waitForApplyOutputs(expectedRecoveryDigest) { throw new Error("Timed out waiting for Launchplane recovery apply evidence."); } -function configureRequestAction(request) { - const launchplaneUrl = input("launchplane-url") || requestString(request, "launchplane_url"); +function optionalDeployReference(request) { + const value = request.deploy_reference; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || value !== value.trim()) { + throw new Error("request-json.deploy_reference must be a string without surrounding space."); + } + return value; +} + +function originalDeployIdentity(request) { const product = requestString(request, "product"); const instance = requestString(request, "instance"); const artifactId = requestString(request, "artifact_id"); const sourceGitRef = requestString(request, "source_git_ref"); const originalRunId = requestPositiveInteger(request, "original_run_id"); const originalRunAttempt = requestPositiveInteger(request, "original_run_attempt"); - const expectedRecoveryDigest = optionalRequestDigest(request); - const reason = requestString(request, "reason"); - const idempotencyKey = [ - "generic-web-stable-deploy", + const keyFormat = request.deploy_key_format ?? "artifact_scoped"; + if (!deployKeyFormats.has(keyFormat)) { + throw new Error("request-json.deploy_key_format must be artifact_scoped or run_scoped."); + } + const deployReference = optionalDeployReference(request); + const deploy = { + schema_version: 1, product, instance, - originalRunId, - originalRunAttempt, - ].join(":"); + artifact_id: artifactId, + source_git_ref: sourceGitRef, + }; + if (keyFormat === "run_scoped") { + if (deployReference !== undefined) { + throw new Error("run_scoped deploy keys predate deploy_reference; omit it."); + } + return { + deploy, + idempotencyKey: [ + "generic-web-stable-deploy", + product, + instance, + originalRunId, + originalRunAttempt, + ].join(":"), + }; + } + deploy.deploy_reference = deployReference ?? ""; + return { + deploy, + idempotencyKey: [ + "generic-web-stable-deploy", + product, + instance, + artifactId, + deploy.deploy_reference, + originalRunId, + originalRunAttempt, + ].join(":"), + }; +} + +function configureRequestAction(request) { + const launchplaneUrl = input("launchplane-url") || requestString(request, "launchplane_url"); + const mode = input("mode"); + if (!recoveryModes.has(mode)) { + throw new Error("mode must be empty or provider-evidence."); + } + const product = requestString(request, "product"); + const instance = requestString(request, "instance"); + const { deploy, idempotencyKey } = originalDeployIdentity(request); + const expectedRecoveryDigest = optionalRequestDigest(request); + if (mode && expectedRecoveryDigest) { + throw new Error("Provider evidence inspection does not accept a recovery digest."); + } + const reason = requestString(request, "reason"); const payload = { schema_version: 1, product, @@ -295,13 +352,7 @@ function configureRequestAction(request) { original_deploy: { schema_version: 1, product, - deploy: { - schema_version: 1, - product, - instance, - artifact_id: artifactId, - source_git_ref: sourceGitRef, - }, + deploy, }, reason, }; @@ -310,9 +361,11 @@ function configureRequestAction(request) { } environment[environmentKey("launchplane-url")] = launchplaneUrl; - environment[environmentKey("route-path")] = expectedRecoveryDigest - ? "/v1/admin/generic-web/deploy-recovery/apply" - : "/v1/admin/generic-web/deploy-recovery/dry-run"; + environment[environmentKey("route-path")] = mode + ? "/v1/admin/generic-web/deploy-recovery/provider-evidence" + : expectedRecoveryDigest + ? "/v1/admin/generic-web/deploy-recovery/apply" + : "/v1/admin/generic-web/deploy-recovery/dry-run"; environment[environmentKey("payload")] = JSON.stringify(payload); environment[environmentKey("idempotency-key")] = idempotencyKey; environment[environmentKey("audience")] = input("audience"); @@ -334,6 +387,12 @@ function configureRequestAction(request) { "provider_status=provider_status", "retry_safe=retry_safe", ].join(","); + } else if (mode) { + environment[environmentKey("fail-result-paths")] = ""; + environment[environmentKey("output-paths")] = [ + "provider_evidence=provider_evidence", + "provider_read_error_class=provider_read_error_class", + ].join(","); } else { environment[environmentKey("output-paths")] = [ "recovery_digest=recovery_digest", diff --git a/tests/test_generic_web_deploy_recovery_action.py b/tests/test_generic_web_deploy_recovery_action.py index 567e8fbfe..3d6a6137e 100644 --- a/tests/test_generic_web_deploy_recovery_action.py +++ b/tests/test_generic_web_deploy_recovery_action.py @@ -7,9 +7,12 @@ import subprocess from pathlib import Path from tempfile import TemporaryDirectory +import re import unittest import zipfile +from tests.support.workflows import load_workflow + ACTION_ENTRYPOINT = Path(".github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs") DOWNLOAD_ENTRYPOINT = Path( @@ -17,6 +20,96 @@ ) ACTION_METADATA = Path(".github/actions/generic-web-deploy-recovery-dry-run/action.yml") REUSABLE_WORKFLOW = Path(".github/workflows/reusable-generic-web-stable-deploy.yml") +REQUEST_ACTION_ENTRYPOINT = Path(".github/actions/launchplane-request/dist/index.js") +WORKFLOW_EXPRESSION = re.compile(r"\$\{\{\s*(inputs|steps\.request\.outputs)\.([a-z_]+)\s*\}\}") + +def _resolve_workflow_expressions( + value: str, *, inputs: dict[str, str], outputs: dict[str, str] +) -> str: + def replace(match: re.Match[str]) -> str: + source = inputs if match.group(1) == "inputs" else outputs + return source[match.group(2)] + + resolved = WORKFLOW_EXPRESSION.sub(replace, value) + if "${{" in resolved: + raise AssertionError(f"Unresolved workflow expression in {value!r}") + return resolved + + +def _read_github_outputs(path: Path) -> dict[str, str]: + outputs: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + name, separator, value = line.partition("=") + if separator: + outputs[name] = value + return outputs + + +def _capture_request_action_call(env: dict[str, str], entrypoint: Path) -> dict[str, object]: + script = f""" +const calls = []; +global.fetch = async (url, init) => {{ + calls.push({{url, headers: init.headers, body: init.body || ''}}); + if (url.startsWith('https://oidc.example/token')) {{ + return new Response(JSON.stringify({{value: 'oidc-token'}}), {{status: 200}}); + }} + return new Response(JSON.stringify({{recovery_digest: 'a'.repeat(64)}}), {{status: 200}}); +}}; +process.on('beforeExit', () => console.error(JSON.stringify(calls))); +await import('./{entrypoint.as_posix()}'); +""" + result = subprocess.run( + ["node", "--input-type=module", "-e", script], + capture_output=True, + env={**os.environ, **env}, + text=True, + ) + if result.returncode != 0: + raise AssertionError(result.stderr) + calls = json.loads(result.stderr.splitlines()[-1]) + return calls[-1] + + +def _stable_deploy_request_call( + *, inputs: dict[str, str], run_id: str, temporary_directory: Path +) -> dict[str, object]: + """Run the reusable stable-deploy request steps exactly as the workflow defines them.""" + workflow = load_workflow(REUSABLE_WORKFLOW) + resolve_step = workflow.step_named("stable-deploy", "Resolve Launchplane deploy request") + request_step = workflow.step_named("stable-deploy", "Request Launchplane generic-web stable deploy") + assert resolve_step is not None and request_step is not None + output_path = temporary_directory / "deploy-request-output.txt" + step_env = { + name: _resolve_workflow_expressions(str(value), inputs=inputs, outputs={}) + for name, value in dict(resolve_step.data["env"]).items() + } + result = subprocess.run( + ["bash", "-c", resolve_step.run], + capture_output=True, + env={ + **os.environ, + **step_env, + "GITHUB_OUTPUT": str(output_path), + "GITHUB_REPOSITORY": "cbusillo/example-product", + "GITHUB_RUN_ID": run_id, + }, + text=True, + ) + if result.returncode != 0: + raise AssertionError(result.stderr) + outputs = _read_github_outputs(output_path) + request_env = { + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.example/token", + "GITHUB_OUTPUT": str(temporary_directory / "deploy-action-output.txt"), + "INPUT_LAUNCHPLANE-URL": "https://launchplane.example", + "INPUT_ROUTE-PATH": str(request_step.with_values["route-path"]), + } + for input_name in ("payload", "payload-fields", "idempotency-key"): + request_env[f"INPUT_{input_name.upper()}"] = _resolve_workflow_expressions( + str(request_step.with_values[input_name]), inputs=inputs, outputs=outputs + ) + return _capture_request_action_call(request_env, REQUEST_ACTION_ENTRYPOINT) class GenericWebDeployRecoveryActionTests(unittest.TestCase): @@ -65,6 +158,7 @@ def run_action( archive_compression: int = zipfile.ZIP_STORED, archive_transform: Callable[[bytes], bytes] | None = None, artifact_total_count: int = 1, + mode: str = "", ) -> subprocess.CompletedProcess[str]: if shutil.which("node") is None: self.skipTest("node is required to test the recovery dry-run action") @@ -80,6 +174,8 @@ def run_action( ) if launchplane_url: env["INPUT_LAUNCHPLANE-URL"] = launchplane_url + if mode: + env["INPUT_MODE"] = mode effective_request = request artifact_archive_data = "" if request_file is not None: @@ -221,6 +317,7 @@ def test_action_reconstructs_exact_legacy_request_and_projects_evidence(self) -> "source_git_ref": "2d66fb6b2708f975b1645ac912a5b576a9282853", "original_run_id": "29609495343", "original_run_attempt": "1", + "deploy_key_format": "run_scoped", "reason": "Inspect the legacy deploy reservation.", } with TemporaryDirectory() as temporary_directory: @@ -274,6 +371,88 @@ def test_action_reconstructs_exact_legacy_request_and_projects_evidence(self) -> self.assertIn(f"{output_name}<<", outputs) self.assertIn(f"\n{output_value}\n", outputs) + def test_action_reaches_reservation_created_by_current_stable_deploy(self) -> None: + if shutil.which("node") is None or shutil.which("bash") is None: + self.skipTest("node and bash are required to run the stable deploy steps") + artifact_id = "ghcr.io/example/product@sha256:" + "c" * 64 + source_git_ref = "2d66fb6b2708f975b1645ac912a5b576a9282853" + for deploy_reference in ("", "ghcr.io/example/product:sha-2d66fb6b2708"): + with self.subTest(deploy_reference=deploy_reference), TemporaryDirectory() as directory: + temporary_directory = Path(directory) + deploy_call = _stable_deploy_request_call( + inputs={ + "artifact_id": artifact_id, + "deploy_reference": deploy_reference, + "instance": "prod", + "product": "example-product", + "source_git_ref": source_git_ref, + }, + run_id="34724620086", + temporary_directory=temporary_directory, + ) + request: dict[str, object] = { + "schema_version": 1, + "product": "example-product", + "instance": "prod", + "artifact_id": artifact_id, + "source_git_ref": source_git_ref, + "original_run_id": "34724620086", + "original_run_attempt": "1", + "reason": "Inspect the stuck deploy reservation.", + } + if deploy_reference: + request["deploy_reference"] = deploy_reference + for mode, route in ( + ("", "dry-run"), + ("provider-evidence", "provider-evidence"), + ): + result = self.run_action( + request=request, + output_path=temporary_directory / f"github-output-{route}.txt", + mode=mode, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + recovery_call = json.loads(result.stderr.splitlines()[-1])[-1] + self.assertTrue(recovery_call["url"].endswith(f"/deploy-recovery/{route}")) + self.assertEqual( + recovery_call["headers"]["Idempotency-Key"], + deploy_call["headers"]["Idempotency-Key"], + ) + self.assertEqual( + json.loads(recovery_call["body"])["original_deploy"], + json.loads(str(deploy_call["body"])), + ) + + def test_action_rejects_inexact_deploy_key_format_before_oidc(self) -> None: + base_request = { + "schema_version": 1, + "product": "repairshopr-sync", + "instance": "prod", + "artifact_id": "artifact", + "source_git_ref": "source", + "original_run_id": "29609495343", + "original_run_attempt": "1", + "reason": "Inspect the deploy reservation.", + } + for extra_fields, message in ( + ({"deploy_key_format": "any"}, "deploy_key_format must be"), + ( + {"deploy_key_format": "run_scoped", "deploy_reference": ""}, + "predate deploy_reference", + ), + ({"deploy_reference": " tag"}, "without surrounding space"), + ): + with self.subTest(extra_fields=extra_fields), TemporaryDirectory() as directory: + result = self.run_action( + request={**base_request, **extra_fields}, + output_path=Path(directory) / "github-output.txt", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + self.assertEqual(json.loads(result.stderr.splitlines()[-1]), []) + def test_action_rejects_explicit_digest_bound_apply_before_oidc(self) -> None: request = { "schema_version": 1, From 345448e5781913557d41a44ff4f1ab9c0cb4315e Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sun, 27 Sep 2026 16:00:23 -0400 Subject: [PATCH 2/2] Route stable-deploy recovery through the fixed key builder Pin the reusable workflow's recovery steps to the action that rebuilds the current stable-deploy key, and replace the workflow's own shell copy of the provider-evidence key and payload with the action's provider-evidence mode. The dispatch path now carries the original deploy_reference. A regression test runs the real stable-deploy request steps and requires recovery to send the same key and payload. Refs #2531 --- .../reusable-generic-web-stable-deploy.yml | 67 ++----------------- docs/operations.md | 23 +++++-- ...test_generic_web_deploy_recovery_action.py | 25 ++++--- 3 files changed, 38 insertions(+), 77 deletions(-) diff --git a/.github/workflows/reusable-generic-web-stable-deploy.yml b/.github/workflows/reusable-generic-web-stable-deploy.yml index 430ca1c46..980c6c762 100644 --- a/.github/workflows/reusable-generic-web-stable-deploy.yml +++ b/.github/workflows/reusable-generic-web-stable-deploy.yml @@ -204,6 +204,7 @@ jobs: id: request env: ARTIFACT_ID: ${{ inputs.artifact_id }} + DEPLOY_REFERENCE: ${{ inputs.deploy_reference }} EXPLICIT_REQUEST: ${{ inputs.recovery_request_json }} INSTANCE: ${{ inputs.instance }} ORIGINAL_RUN_ATTEMPT: ${{ github.event.inputs.original_run_attempt }} @@ -259,6 +260,7 @@ jobs: --arg product "$PRODUCT" \ --arg instance "$INSTANCE" \ --arg artifact_id "$ARTIFACT_ID" \ + --arg deploy_reference "$DEPLOY_REFERENCE" \ --arg source_git_ref "$SOURCE_GIT_REF" \ --arg original_run_id "$ORIGINAL_RUN_ID" \ --arg original_run_attempt "$ORIGINAL_RUN_ATTEMPT" \ @@ -269,6 +271,7 @@ jobs: product: $product, instance: $instance, artifact_id: $artifact_id, + deploy_reference: $deploy_reference, source_git_ref: $source_git_ref, original_run_id: $original_run_id, original_run_attempt: $original_run_attempt, @@ -282,75 +285,19 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" - - name: Resolve provider evidence request - id: provider_evidence_request - continue-on-error: true - env: - RECOVERY_REQUEST: ${{ steps.request.outputs.request }} - run: | - set -euo pipefail - - launchplane_url="$(jq -er '.launchplane_url | select(type == "string" and length > 0)' <<< "$RECOVERY_REQUEST")" - product="$(jq -er '.product | select(type == "string" and length > 0)' <<< "$RECOVERY_REQUEST")" - instance="$(jq -er '.instance | select(type == "string" and length > 0)' <<< "$RECOVERY_REQUEST")" - artifact_id="$(jq -er '.artifact_id | select(type == "string" and length > 0)' <<< "$RECOVERY_REQUEST")" - source_git_ref="$(jq -er '.source_git_ref | select(type == "string" and length > 0)' <<< "$RECOVERY_REQUEST")" - original_run_id="$(jq -er '.original_run_id | select(type == "string" and test("^[1-9][0-9]*$"))' <<< "$RECOVERY_REQUEST")" - original_run_attempt="$(jq -er '.original_run_attempt | select(type == "string" and test("^[1-9][0-9]*$"))' <<< "$RECOVERY_REQUEST")" - reason="$(jq -er '.reason | select(type == "string" and length > 0)' <<< "$RECOVERY_REQUEST")" - payload="$(jq -cn \ - --arg product "$product" \ - --arg instance "$instance" \ - --arg artifact_id "$artifact_id" \ - --arg source_git_ref "$source_git_ref" \ - --arg reason "$reason" \ - '{ - schema_version: 1, - product: $product, - instance: $instance, - original_deploy: { - schema_version: 1, - product: $product, - deploy: { - schema_version: 1, - product: $product, - instance: $instance, - artifact_id: $artifact_id, - source_git_ref: $source_git_ref - } - }, - reason: $reason - }')" - idempotency_key="generic-web-stable-deploy:${product}:${instance}:${original_run_id}:${original_run_attempt}" - - { - echo "launchplane_url=$launchplane_url" - echo "idempotency_key=$idempotency_key" - echo "payload<> "$GITHUB_OUTPUT" - - name: Inspect exact provider evidence id: provider_evidence continue-on-error: true - uses: cbusillo/launchplane/.github/actions/launchplane-request@052db9d452f05381a3b43a82dfddfeefb34a8b72 # launchplane-request + uses: cbusillo/launchplane/.github/actions/generic-web-deploy-recovery-dry-run@6bac61a1967c6adce8bdfd32cfbcdae362134a34 # main with: - launchplane-url: ${{ steps.provider_evidence_request.outputs.launchplane_url }} + request-json: ${{ steps.request.outputs.request }} + mode: provider-evidence audience: ${{ inputs.launchplane_audience }} - route-path: /v1/admin/generic-web/deploy-recovery/provider-evidence - payload: ${{ steps.provider_evidence_request.outputs.payload }} - idempotency-key: ${{ steps.provider_evidence_request.outputs.idempotency_key }} timeout-ms: ${{ inputs['timeout-ms'] }} - fail-result-paths: "" - output-paths: >- - provider_evidence=provider_evidence, - provider_read_error_class=provider_read_error_class - log-response-body: false - name: Request Launchplane recovery dry run id: recovery - uses: cbusillo/launchplane/.github/actions/generic-web-deploy-recovery-dry-run@b2055d2944626234664390d6fcd96975ded38511 # main + uses: cbusillo/launchplane/.github/actions/generic-web-deploy-recovery-dry-run@6bac61a1967c6adce8bdfd32cfbcdae362134a34 # main with: request-json: ${{ steps.request.outputs.request }} timeout-ms: ${{ inputs['timeout-ms'] }} diff --git a/docs/operations.md b/docs/operations.md index 54e9ff077..9e65ecb00 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -495,12 +495,22 @@ original payloads, target URLs, and provider payloads are never returned. 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, 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 +object accepts only the exact original deploy coordinates, optional +`deploy_reference`, original GitHub Actions run ID and attempt, operator reason, +optional `deploy_key_format`, and optional connector-only `launchplane_url`. The +action strips the connector URL before constructing the service payload, +reconstructs the original idempotency key and payload 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. +The default `artifact_scoped` format matches the current stable-deploy key +`generic-web-stable-deploy:{product}:{instance}:{artifact_id}:{deploy_reference}:{run_id}:{attempt}` +and sends `deploy_reference` (empty when omitted) in the original payload. +Since 2026-08-16 the stable deploy always uses attempt `1`, so recover a rerun +deploy with `original_run_attempt` `1`. Reservations made before 2026-08-05 use +`deploy_key_format` `run_scoped`, which rebuilds +`generic-web-stable-deploy:{product}:{instance}:{run_id}:{attempt}` without +`deploy_reference`. Either format is one exact key and payload fingerprint; a +wrong format or coordinate returns `reservation_not_found` and changes nothing. 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 @@ -523,7 +533,8 @@ request through the same bounded dry-run action. This mode also skips stable deploy and exposes no apply path. The same protected recovery job performs an advisory exact-provider evidence -read before the authoritative dry-run. It calls +read before the authoritative dry-run. It runs the same action with +`mode: provider-evidence`, which calls `POST /v1/admin/generic-web/deploy-recovery/provider-evidence` with the identical request and original `Idempotency-Key`. The route derives provider operation and target identity only from the exact stored reservation and reconciliation diff --git a/tests/test_generic_web_deploy_recovery_action.py b/tests/test_generic_web_deploy_recovery_action.py index 3d6a6137e..1a65f7707 100644 --- a/tests/test_generic_web_deploy_recovery_action.py +++ b/tests/test_generic_web_deploy_recovery_action.py @@ -8,6 +8,7 @@ from pathlib import Path from tempfile import TemporaryDirectory import re +from typing import Any, cast import unittest import zipfile @@ -21,7 +22,8 @@ ACTION_METADATA = Path(".github/actions/generic-web-deploy-recovery-dry-run/action.yml") REUSABLE_WORKFLOW = Path(".github/workflows/reusable-generic-web-stable-deploy.yml") REQUEST_ACTION_ENTRYPOINT = Path(".github/actions/launchplane-request/dist/index.js") -WORKFLOW_EXPRESSION = re.compile(r"\$\{\{\s*(inputs|steps\.request\.outputs)\.([a-z_]+)\s*\}\}") +WORKFLOW_EXPRESSION = re.compile(r"\$\{\{\s*(inputs|steps\.request\.outputs)\.([a-z_]+)\s*}}") + def _resolve_workflow_expressions( value: str, *, inputs: dict[str, str], outputs: dict[str, str] @@ -45,7 +47,7 @@ def _read_github_outputs(path: Path) -> dict[str, str]: return outputs -def _capture_request_action_call(env: dict[str, str], entrypoint: Path) -> dict[str, object]: +def _capture_request_action_call(env: dict[str, str], entrypoint: Path) -> dict[str, Any]: script = f""" const calls = []; global.fetch = async (url, init) => {{ @@ -67,21 +69,25 @@ def _capture_request_action_call(env: dict[str, str], entrypoint: Path) -> dict[ if result.returncode != 0: raise AssertionError(result.stderr) calls = json.loads(result.stderr.splitlines()[-1]) - return calls[-1] + return cast(dict[str, Any], calls[-1]) def _stable_deploy_request_call( *, inputs: dict[str, str], run_id: str, temporary_directory: Path -) -> dict[str, object]: +) -> dict[str, Any]: """Run the reusable stable-deploy request steps exactly as the workflow defines them.""" workflow = load_workflow(REUSABLE_WORKFLOW) resolve_step = workflow.step_named("stable-deploy", "Resolve Launchplane deploy request") - request_step = workflow.step_named("stable-deploy", "Request Launchplane generic-web stable deploy") + request_step = workflow.step_named( + "stable-deploy", "Request Launchplane generic-web stable deploy" + ) assert resolve_step is not None and request_step is not None output_path = temporary_directory / "deploy-request-output.txt" + raw_env = resolve_step.data["env"] + assert isinstance(raw_env, dict) step_env = { name: _resolve_workflow_expressions(str(value), inputs=inputs, outputs={}) - for name, value in dict(resolve_step.data["env"]).items() + for name, value in raw_env.items() } result = subprocess.run( ["bash", "-c", resolve_step.run], @@ -930,15 +936,12 @@ def test_stable_deploy_reusable_workflow_has_dry_run_only_recovery_mode(self) -> "RECOVERY_ARTIFACT_RUN_ID: ${{ github.event.workflow_run.id }}", "Recovery request artifact must contain exactly one file.", "Recovery request artifact exceeds the size limit.", - "name: Resolve provider evidence request", "name: Inspect exact provider evidence", - "route-path: /v1/admin/generic-web/deploy-recovery/provider-evidence", - "provider_evidence=provider_evidence", - "provider_read_error_class=provider_read_error_class", + "mode: provider-evidence", "continue-on-error: true", "name: Request Launchplane recovery dry run", "uses: cbusillo/launchplane/.github/actions/" - "generic-web-deploy-recovery-dry-run@b2055d2944626234664390d6fcd96975ded38511", + "generic-web-deploy-recovery-dry-run@6bac61a1967c6adce8bdfd32cfbcdae362134a34", "request-json: ${{ steps.request.outputs.request }}", "Recovery digest:", "Proposed action:",