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
19 changes: 15 additions & 4 deletions .github/actions/generic-web-deploy-recovery-dry-run/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
121 changes: 90 additions & 31 deletions .github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
Expand Down Expand Up @@ -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.");
}

Expand Down Expand Up @@ -271,37 +271,88 @@ 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,
instance,
original_deploy: {
schema_version: 1,
product,
deploy: {
schema_version: 1,
product,
instance,
artifact_id: artifactId,
source_git_ref: sourceGitRef,
},
deploy,
},
reason,
};
Expand All @@ -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");
Expand All @@ -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",
Expand Down
67 changes: 7 additions & 60 deletions .github/workflows/reusable-generic-web-stable-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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" \
Expand All @@ -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,
Expand All @@ -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<<EOF"
echo "$payload"
echo "EOF"
} >> "$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'] }}
Expand Down
23 changes: 17 additions & 6 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading