diff --git a/.github/actions/launchplane-request/action.yml b/.github/actions/launchplane-request/action.yml index 95e2a27d..d036f375 100644 --- a/.github/actions/launchplane-request/action.yml +++ b/.github/actions/launchplane-request/action.yml @@ -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 diff --git a/.github/actions/launchplane-request/dist/index.js b/.github/actions/launchplane-request/dist/index.js index 5ae1de3e..9a33fafb 100644 --- a/.github/actions/launchplane-request/dist/index.js +++ b/.github/actions/launchplane-request/dist/index.js @@ -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(",") @@ -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 = { diff --git a/docs/product-repo-contract.md b/docs/product-repo-contract.md index 01e4d1a2..a6a7af0b 100644 --- a/docs/product-repo-contract.md +++ b/docs/product-repo-contract.md @@ -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: diff --git a/tests/test_launchplane_request_action.py b/tests/test_launchplane_request_action.py index b4a23e9e..f564d7dd 100644 --- a/tests/test_launchplane_request_action.py +++ b/tests/test_launchplane_request_action.py @@ -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={ @@ -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={