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
8 changes: 8 additions & 0 deletions .github/actions/launchplane-request/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ inputs:
are valid JSON literals or objects. Example: request.reason=manual.
required: false
default: ""
payload-json-files:
description: >-
Newline-separated JSON path assignments to overlay onto the payload after
payload-fields. Assignment values are local file paths whose parsed JSON
contents are written at the target path. Example:
publish.manifest=artifact.json.
required: false
default: ""
method:
description: HTTP method to use.
required: false
Expand Down
30 changes: 29 additions & 1 deletion .github/actions/launchplane-request/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,34 @@ function applyPayloadFields(payload) {
return payload;
}

function applyPayloadJsonFiles(payload) {
const payloadJsonFiles = getInput("payload-json-files");
if (!payloadJsonFiles) {
return payload;
}
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("payload-json-files requires payload or payload-file to contain a JSON object.");
}
for (const line of payloadJsonFiles.split(/\r?\n/)) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) {
continue;
}
const separatorIndex = trimmedLine.indexOf("=");
if (separatorIndex <= 0) {
throw new Error(`Invalid payload JSON file '${trimmedLine}'. Use json.path=file-path.`);
}
const path = trimmedLine.slice(0, separatorIndex).trim();
const filePath = line.slice(line.indexOf("=") + 1).trim();
if (!filePath) {
throw new Error(`Payload JSON file '${path}' requires a file path.`);
}
const fileValue = JSON.parse(fs.readFileSync(filePath, "utf8"));
writeJsonPath(payload, path, fileValue);
}
return payload;
}

function splitCommaSeparated(value) {
return String(value ?? "")
.split(",")
Expand Down Expand Up @@ -354,7 +382,7 @@ async function main() {
const requestUrl = new URL(routePath, launchplaneUrl).toString();
const audience = getInput("audience") || launchplaneUrl.host;
const idempotencyKey = getInput("idempotency-key");
const payload = applyPayloadFields(readPayload());
const payload = applyPayloadJsonFiles(applyPayloadFields(readPayload()));
const options = getActionOptions();
const token = await requestGitHubOidcToken(audience, options);
const headers = {
Expand Down
18 changes: 15 additions & 3 deletions docs/product-repo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,21 @@ payload-fields: |-

Each `payload-fields` line is `json.path=value`. Values are strings unless they
parse as JSON literals or objects, so `false`, `300`, and `{}` keep their JSON
types. Product repos may still need small payload-builder scripts for large
manifest-derived payloads until Launchplane owns the next layer of
product-specific request assembly.
types. Use `payload-json-files` when a workflow already has a JSON artifact file
and only needs to splice it into a static Launchplane request:

```yaml
payload: >-
{"schema_version":1,"product":"odoo","publish":{"schema_version":1}}
payload-fields: |-
publish.context=cm
publish.instance=${{ github.event.inputs.instance }}
payload-json-files: |-
publish.manifest=${{ steps.publish.outputs.manifest_file }}
```

Each `payload-json-files` line is `json.path=file-path`; the action parses the
file as JSON and writes that value into the request before sending it.

For asynchronous Launchplane routes that report a temporary status, configure
polling instead of reimplementing OIDC and retry logic in the product repo:
Expand Down
41 changes: 41 additions & 0 deletions tests/test_launchplane_request_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@ def test_overlays_payload_fields_before_request(self) -> None:
self.assertIs(payload["rollback"]["wait_for_deploy"], False)
self.assertEqual(payload["rollback"]["timeout_seconds"], 300)

def test_overlays_payload_json_files_before_request(self) -> None:
with TemporaryDirectory() as temporary_directory:
manifest_path = Path(temporary_directory) / "artifact.json"
manifest_path.write_text(
json.dumps(
{
"artifact_id": "artifact-cm-123",
"source_commit": "abc123",
}
),
encoding="utf-8",
)
result = self.run_action(
inputs={
"launchplane-url": "https://launchplane.example",
"route-path": "/v1/drivers/odoo/artifact-publish",
"payload": '{"schema_version":1,"product":"odoo","publish":{"schema_version":1,"context":"cm"}}',
"payload-fields": "publish.instance=testing",
"payload-json-files": f"publish.manifest={manifest_path}",
},
)

self.assertEqual(result.returncode, 0, result.stderr)
calls = json.loads(result.stderr.strip().splitlines()[-1])
payload = json.loads(calls[1]["body"])
self.assertEqual(payload["publish"]["instance"], "testing")
self.assertEqual(payload["publish"]["manifest"]["artifact_id"], "artifact-cm-123")
self.assertEqual(payload["publish"]["manifest"]["source_commit"], "abc123")

def test_rejects_payload_fields_without_object_payload(self) -> None:
result = self.run_action(
inputs={
Expand All @@ -173,6 +202,18 @@ def test_rejects_payload_fields_without_object_payload(self) -> None:
self.assertNotEqual(result.returncode, 0)
self.assertIn("payload-fields requires payload or payload-file", result.stderr)

def test_rejects_payload_json_files_without_object_payload(self) -> None:
result = self.run_action(
inputs={
"launchplane-url": "https://launchplane.example",
"route-path": "/v1/drivers/odoo/artifact-publish",
"payload-json-files": "publish.manifest=artifact.json",
},
)

self.assertNotEqual(result.returncode, 0)
self.assertIn("payload-json-files requires payload or payload-file", result.stderr)

def test_fails_when_driver_result_status_is_configured_as_failure(self) -> None:
result = self.run_action(
inputs={
Expand Down