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
39 changes: 39 additions & 0 deletions .github/actions/generic-web-deploy-recovery-dry-run/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
name: Generic-web deploy recovery dry run
description: Inspect one exact legacy generic-web deploy reservation through Launchplane.

inputs:
launchplane-url:
description: Base URL for the Launchplane service.
required: true
request-json:
description: Exact legacy deploy coordinates and operator reason as JSON.
required: true
audience:
description: Optional GitHub OIDC audience. Defaults to the service URL host.
required: false
default: ""
timeout-ms:
description: Launchplane request timeout in milliseconds.
required: false
default: "120000"

outputs:
recovery_digest:
description: Canonical digest binding the observed recovery plan.
proposed_action:
description: Recovery action proposed by Launchplane.
reservation_state:
description: Current durable reservation state.
provider_outcome:
description: Bounded provider inspection outcome.
provider_status:
description: Bounded provider status.
retry_safe:
description: Whether retrying the original operation is safe.
observed_at:
description: Timestamp for the dry-run observation.

runs:
using: node24
main: dist/index.mjs
128 changes: 128 additions & 0 deletions .github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const runtime = Reflect.get(globalThis, "process");
const environment = runtime.env;
const requestKeys = new Set([
"artifact_id",
"instance",
"original_run_attempt",
"original_run_id",
"product",
"reason",
"schema_version",
"source_git_ref",
]);

function environmentKey(name) {
return `INPUT_${name.replaceAll(" ", "_").toUpperCase()}`;
}

function input(name, defaultValue = "") {
return String(environment[environmentKey(name)] ?? defaultValue).trim();
}

function requiredInput(name) {
const value = input(name);
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}

function requestString(request, name) {
const value = request[name];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`request-json.${name} must be a non-empty string.`);
}
return value.trim();
}

function requestPositiveInteger(request, name) {
const value = requestString(request, name);
if (!/^[1-9][0-9]*$/.test(value)) {
throw new Error(`request-json.${name} must be a positive integer string.`);
}
return value;
}

function parseRequest(value) {
let request;
try {
request = JSON.parse(value);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`request-json must be valid JSON: ${detail}`);
}
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("request-json must be a JSON object.");
}
const unexpectedKeys = Object.keys(request).filter(key => !requestKeys.has(key));
if (unexpectedKeys.length > 0) {
throw new Error(
`request-json contains unsupported fields: ${unexpectedKeys.sort().join(", ")}.`,
);
}
if (request.schema_version !== 1) {
throw new Error("request-json.schema_version must equal 1.");
}
return request;
}

function configureRequestAction() {
const request = parseRequest(requiredInput("request-json"));
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 reason = requestString(request, "reason");
const idempotencyKey = [
"generic-web-stable-deploy",
product,
instance,
originalRunId,
originalRunAttempt,
].join(":");
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,
},
},
reason,
};

environment[environmentKey("launchplane-url")] = requiredInput("launchplane-url");
environment[environmentKey("route-path")] =
"/v1/admin/generic-web/deploy-recovery/dry-run";
environment[environmentKey("payload")] = JSON.stringify(payload);
environment[environmentKey("idempotency-key")] = idempotencyKey;
environment[environmentKey("audience")] = input("audience");
environment[environmentKey("timeout-ms")] = input("timeout-ms", "120000");
environment[environmentKey("log-response-body")] = "false";
environment[environmentKey("output-paths")] = [
"recovery_digest=recovery_digest",
"proposed_action=proposed_action",
"reservation_state=reservation_state",
"provider_outcome=provider_outcome",
"provider_status=provider_status",
"retry_safe=retry_safe",
"observed_at=observed_at",
].join(",");
}

try {
configureRequestAction();
await import(new URL("../../launchplane-request/dist/index.js", import.meta.url));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
runtime.exitCode = 1;
}
8 changes: 8 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ timestamps, hashed identifiers, provider outcome/status, retry safety, one of
`retry_original_operation`, or `hold_unknown`, and a canonical recovery digest.
Raw scopes, idempotency keys, reconciliation keys, provider-target keys,
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, 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.

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
172 changes: 172 additions & 0 deletions tests/test_generic_web_deploy_recovery_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import json
import os
import shutil
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest


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")


class GenericWebDeployRecoveryActionTests(unittest.TestCase):
def run_action(
self,
*,
request: dict[str, object],
output_path: Path,
) -> subprocess.CompletedProcess[str]:
if shutil.which("node") is None:
self.skipTest("node is required to test the recovery dry-run action")

env = os.environ.copy()
env.update(
{
"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),
}
)
script = f"""
const calls = [];
global.fetch = async (url, init) => {{
calls.push({{url, init}});
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" * 64}',
proposed_action: 'retry_original_operation',
reservation_state: 'reconcile_required',
provider_outcome: 'absent',
provider_status: 'missing',
retry_safe: true,
observed_at: '2026-08-16T22:00:00Z'
}}), {{status: 200}});
}};
process.on('beforeExit', () => {{
console.error(JSON.stringify(calls.map((call) => ({{
url: call.url,
method: call.init.method,
headers: call.init.headers,
body: call.init.body || ''
}}))));
}});
import('./{ACTION_ENTRYPOINT.as_posix()}');
"""
return subprocess.run(
["node", "-e", script],
capture_output=True,
env=env,
text=True,
)

def test_action_metadata_is_dry_run_only_and_declares_bounded_outputs(self) -> None:
metadata = ACTION_METADATA.read_text(encoding="utf-8")

self.assertIn("using: node24", metadata)
self.assertNotIn("apply", metadata.lower())
for output_name in (
"recovery_digest",
"proposed_action",
"reservation_state",
"provider_outcome",
"provider_status",
"retry_safe",
"observed_at",
):
with self.subTest(output_name=output_name):
self.assertIn(f" {output_name}:\n", metadata)

def test_action_reconstructs_exact_legacy_request_and_projects_evidence(self) -> None:
request = {
"schema_version": 1,
"product": "repairshopr-sync",
"instance": "prod",
"artifact_id": "ghcr.io/cbusillo/repairshopr_api@sha256:" + "b" * 64,
"source_git_ref": "2d66fb6b2708f975b1645ac912a5b576a9282853",
"original_run_id": "29609495343",
"original_run_attempt": "1",
"reason": "Inspect the legacy deploy reservation.",
}
with TemporaryDirectory() as temporary_directory:
output_path = Path(temporary_directory) / "github-output.txt"
result = self.run_action(request=request, output_path=output_path)

self.assertEqual(result.returncode, 0, result.stderr)
calls = json.loads(result.stderr.splitlines()[-1])
self.assertEqual(len(calls), 2)
launchplane_call = calls[1]
self.assertEqual(
launchplane_call["url"],
"https://launchplane.example/v1/admin/generic-web/deploy-recovery/dry-run",
)
self.assertEqual(launchplane_call["method"], "POST")
self.assertEqual(
launchplane_call["headers"]["Idempotency-Key"],
"generic-web-stable-deploy:repairshopr-sync:prod:29609495343:1",
)
self.assertEqual(
json.loads(launchplane_call["body"]),
{
"schema_version": 1,
"product": "repairshopr-sync",
"instance": "prod",
"original_deploy": {
"schema_version": 1,
"product": "repairshopr-sync",
"deploy": {
"schema_version": 1,
"product": "repairshopr-sync",
"instance": "prod",
"artifact_id": request["artifact_id"],
"source_git_ref": request["source_git_ref"],
},
},
"reason": request["reason"],
},
)
outputs = output_path.read_text(encoding="utf-8")
for output_name, output_value in (
("recovery_digest", "a" * 64),
("proposed_action", "retry_original_operation"),
("reservation_state", "reconcile_required"),
("provider_outcome", "absent"),
("provider_status", "missing"),
("retry_safe", "true"),
("observed_at", "2026-08-16T22:00:00Z"),
):
with self.subTest(output_name=output_name):
self.assertIn(f"{output_name}<<", outputs)
self.assertIn(f"\n{output_value}\n", outputs)

def test_action_rejects_unknown_request_fields_before_oidc(self) -> None:
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 legacy deploy reservation.",
"apply": True,
}
with TemporaryDirectory() as temporary_directory:
result = self.run_action(
request=request,
output_path=Path(temporary_directory) / "github-output.txt",
)

self.assertNotEqual(result.returncode, 0)
self.assertIn("unsupported fields: apply", result.stderr)
calls = json.loads(result.stderr.splitlines()[-1])
self.assertEqual(calls, [])


if __name__ == "__main__":
unittest.main()
Loading