diff --git a/README.md b/README.md index 2a02165..7092b35 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Features: - Sequential multi-file execution with configurable failure handling - `validate_first` strategy: validate all files before executing any - Variable injection via `INPROD_CHANGESET_VARIABLES` +- File uploads via `INPROD_FILES` (InProd's temp-file store, 24h signed URLs) - Output artifacts consumable by downstream jobs --- @@ -63,6 +64,7 @@ All configuration is passed via environment variables. Variables can be set at t | `INPROD_EXECUTION_STRATEGY` | No | `"per_file"` | `"per_file"`: validate+execute each file in sequence. `"validate_first"`: validate all files, then execute all | | `INPROD_FAIL_FAST` | No | `"false"` | Stop processing on first failure when `"true"` | | `INPROD_CHANGESET_VARIABLES` | No | `""` | Newline-separated `KEY=VALUE` pairs to inject into changesets at runtime | +| `INPROD_FILES` | No | `""` | Newline-separated `VARNAME=path` pairs. Each file is uploaded to InProd's temp-file store and the returned signed URL is injected as a changeset variable (see [File Uploads](#file-uploads)) | ### Boolean Variables @@ -124,6 +126,50 @@ See your platform guide for how to pass multi-line values securely. --- +## File Uploads + +Genesys Cloud file fields (`BowFileField`) need a URL the InProd server can fetch. If the file only +exists in your repository — a prompt `.wav`, an MoH file, an image — use `INPROD_FILES` to have +`run-changesets` upload it to InProd's ephemeral temp-file store and inject the returned signed URL +as a changeset variable. + +**Format:** One `VARNAME=path` pair per line, mirroring `INPROD_CHANGESET_VARIABLES`. Blank lines +and `#`-comments are ignored; paths are resolved relative to the working directory (repo root), the +same as `INPROD_CHANGESET_FILE`. + +``` +MOH_URL=./assets/moh.wav +PROMPT_AUDIO_URL=./assets/greeting.wav +``` + +Notes: + +- `VARNAME` must be ≤ 40 characters, a valid JavaScript identifier, and not a reserved word (or + `callback_url`) — invalid names fail before any upload. +- `VARNAME` must not already exist as a **masked** variable anywhere in the changeset(s) being + processed. File-URL variables can never be masked (masked variables are stripped from the script + scope before the tag below is evaluated), so a name collision with an existing masked variable is + rejected up front with a clear error, rather than silently unmasked. +- Every file is uploaded fresh on every run; the signed URL expires 24 hours after upload and is + never cached or reused across invocations. +- Reference the variable in your changeset with the `[?? ?? ]` script tag, e.g. + `file_url: '[?? MOH_URL ??]'`. Confirm the exact field nesting for your model by exporting a + changeset that already has a file field. + +Example (GitLab CI): + +```yaml +deploy-changesets: + script: + - export INPROD_FILES="MOH_URL=./assets/moh.wav" + - npx run-changesets + variables: + INPROD_BASE_URL: https://tenant1.inprod.io + # INPROD_API_KEY provided as a masked CI variable +``` + +--- + ## Debug Logging Set `INPROD_DEBUG=true` as an environment variable or pipeline variable to enable verbose debug output, including API request and response details. @@ -144,6 +190,12 @@ Set `INPROD_DEBUG=true` as an environment variable or pipeline variable to enabl | `Changeset validation failed` | Changeset has validation errors | Review the validation errors in the job log | | `did not complete within N seconds` | Task polling timed out | Increase `INPROD_POLLING_TIMEOUT_MINUTES` | | `Invalid changeset_variables format` | A line in `INPROD_CHANGESET_VARIABLES` has no `=` | Ensure every non-comment line is `KEY=VALUE` | +| `INPROD_FILES: malformed entry "..."` | A line isn't `VARNAME=path` | Ensure every non-comment line is `VARNAME=path` | +| `INPROD_FILES: invalid variable name "..."` | Name is too long, not a valid identifier, or reserved | Use a short identifier-style name that isn't a JS reserved word or `callback_url` | +| `INPROD_FILES: ... file not found` / `not readable` | Path is wrong or unreadable | Check the path is relative to the repo root and the file is committed and readable | +| `INPROD_FILES: "..." is not a valid variable — it is already declared as masked in ...` | An `INPROD_FILES` name collides with an existing masked variable in the changeset | Rename the `INPROD_FILES` variable, or remove/unmask the conflicting declaration in the changeset | +| `Temp-file upload failed: ... exceeds the server size limit` | File is larger than the server's max upload size | Reduce the file size or ask your InProd admin about `CHANGESET_TEMP_FILE_MAX_BYTES` | +| `Temp-file upload failed for ...: HTTP ...` | Upload request failed (auth, 5xx, etc.) | Check `INPROD_API_KEY` and InProd service status | --- @@ -174,7 +226,7 @@ To run tests with coverage: npm run test:coverage ``` -The test suite uses Jest with fake timers for polling tests. All 107 tests must pass before any changes are merged. +The test suite uses Jest with fake timers for polling tests. All 137 tests must pass before any changes are merged. ### Linting @@ -200,10 +252,11 @@ The package is a single Node.js script (`src/index.js`) that: 1. Reads configuration from `INPROD_*` environment variables 2. Resolves changeset files from the path or glob pattern in `INPROD_CHANGESET_FILE` -3. For each file, optionally validates it against the InProd API, then executes it -4. Polls the InProd task API until the task completes or times out -5. Writes `inprod-results.env` and `inprod-result.json` with the aggregate and per-file results -6. Exits with code `0` on success or `1` on any failure +3. If `INPROD_FILES` is set, uploads each referenced file to InProd's temp-file store and merges the returned signed URLs into the changeset variable set +4. For each file, optionally validates it against the InProd API, then executes it +5. Polls the InProd task API until the task completes or times out +6. Writes `inprod-results.env` and `inprod-result.json` with the aggregate and per-file results +7. Exits with code `0` on success or `1` on any failure The package has no CI-platform-specific dependencies — it reads env vars, writes files, and exits, making it compatible with any CI system. diff --git a/docs/azure.md b/docs/azure.md index 4b99a66..9891b84 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -321,6 +321,31 @@ steps: --- +## File Uploads + +Genesys Cloud file fields (`BowFileField`) need a URL the InProd server can fetch. If the file only +exists in your repository — a prompt `.wav`, an MoH file, an image — use `INPROD_FILES` to have +`run-changesets` upload it to InProd's ephemeral temp-file store and inject the returned signed URL +as a changeset variable. Set it as a pipeline variable, the same way as `INPROD_DEBUG` below — the +package reads `INPROD_FILES` from the environment automatically: + +```yaml +variables: + INPROD_FILES: | + MOH_URL=./assets/moh.wav + PROMPT_AUDIO_URL=./assets/greeting.wav +``` + +**Format:** One `VARNAME=path` pair per line, mirroring `INPROD_CHANGESET_VARIABLES`. Blank lines +and `#`-comments are ignored; paths are resolved relative to the repo root. `VARNAME` must be ≤ 40 +characters, a valid JavaScript identifier, not a reserved word or `callback_url`, and must not +already exist as a masked variable in the changeset — invalid names or masked-name collisions fail +before any upload. Every file is uploaded fresh on every run; the signed URL expires 24 hours after +upload and is never cached or reused. Reference the variable in your changeset with the `[?? ?? ]` +script tag, e.g. `file_url: '[?? MOH_URL ??]'`. + +--- + ## Debug Logging Set `INPROD_DEBUG` to `'true'` as a pipeline variable to enable verbose debug output, including API request/response details: @@ -348,6 +373,12 @@ Or add it directly to the step template's env block by setting it as a pipeline | `Changeset validation failed` | Changeset has validation errors | Review the validation errors in the job log | | `did not complete within N seconds` | Task polling timed out | Increase `pollingTimeoutMinutes` | | `Invalid changeset_variables format` | A line in `changesetVariables` has no `=` | Ensure every non-comment line is `KEY=VALUE` | +| `INPROD_FILES: malformed entry "..."` | A line isn't `VARNAME=path` | Ensure every non-comment line is `VARNAME=path` | +| `INPROD_FILES: invalid variable name "..."` | Name is too long, not a valid identifier, or reserved | Use a short identifier-style name that isn't a JS reserved word or `callback_url` | +| `INPROD_FILES: ... file not found` / `not readable` | Path is wrong or unreadable | Check the path is relative to the repo root and the file is committed and readable | +| `INPROD_FILES: "..." is not a valid variable — it is already declared as masked in ...` | An `INPROD_FILES` name collides with an existing masked variable in the changeset | Rename the `INPROD_FILES` variable, or remove/unmask the conflicting declaration | +| `Temp-file upload failed: ... exceeds the server size limit` | File is larger than the server's max upload size | Reduce the file size or ask your InProd admin about `CHANGESET_TEMP_FILE_MAX_BYTES` | +| `Temp-file upload failed for ...: HTTP ...` | Upload request failed (auth, 5xx, etc.) | Check `INPROD_API_KEY` and InProd service status | | Secret variables appear empty in scripts | Azure does not auto-pass secrets | The template handles this — ensure variables are defined at the pipeline or library level, not hard-coded in YAML | | `Node.js not found` on self-hosted agents | Node is not pre-installed | Either install Node on the agent, or the `NodeTool@0` step will install it if the agent has internet access | | Cross-job output variable is empty | Job name or step name mismatch | Use exact job name and `PublishStatus.INPROD_STATUS` — the step is always named `PublishStatus` | diff --git a/docs/bitbucket.md b/docs/bitbucket.md index 82405ac..8f08021 100644 --- a/docs/bitbucket.md +++ b/docs/bitbucket.md @@ -59,6 +59,7 @@ All configuration uses `INPROD_*` environment variables. Set them inline in your | `INPROD_EXECUTION_STRATEGY` | No | `"per_file"` | `"per_file"` or `"validate_first"` | | `INPROD_FAIL_FAST` | No | `"false"` | Stop on first failure | | `INPROD_CHANGESET_VARIABLES` | No | `""` | Newline-separated `KEY=VALUE` pairs to inject into changesets | +| `INPROD_FILES` | No | `""` | Newline-separated `VARNAME=path` pairs. Each file is uploaded to InProd's temp-file store and the returned signed URL is injected as a changeset variable | --- @@ -294,6 +295,37 @@ Or store `INPROD_CHANGESET_VARIABLES` as a secured repository variable if it con --- +## File Uploads + +Genesys Cloud file fields (`BowFileField`) need a URL the InProd server can fetch. If the file only +exists in your repository — a prompt `.wav`, an MoH file, an image — use `INPROD_FILES` to have +`run-changesets` upload it to InProd's ephemeral temp-file store and inject the returned signed URL +as a changeset variable. + +```yaml +- export INPROD_FILES="MOH_URL=./assets/moh.wav" +- npx --yes @inprod.io/run-changesets +``` + +For multiple files, use a heredoc, same as `INPROD_CHANGESET_VARIABLES`: + +```yaml +- | + export INPROD_FILES="MOH_URL=./assets/moh.wav + PROMPT_AUDIO_URL=./assets/greeting.wav" +- npx --yes @inprod.io/run-changesets +``` + +**Format:** One `VARNAME=path` pair per line, mirroring `INPROD_CHANGESET_VARIABLES`. Blank lines +and `#`-comments are ignored; paths are resolved relative to the repo root. `VARNAME` must be ≤ 40 +characters, a valid JavaScript identifier, not a reserved word or `callback_url`, and must not +already exist as a masked variable in the changeset — invalid names or masked-name collisions fail +before any upload. Every file is uploaded fresh on every run; the signed URL expires 24 hours after +upload and is never cached or reused. Reference the variable in your changeset with the `[?? ?? ]` +script tag, e.g. `file_url: '[?? MOH_URL ??]'`. + +--- + ## Debug Logging Set `INPROD_DEBUG=true` to enable verbose output including API request and response details: @@ -326,4 +358,10 @@ Or add `INPROD_DEBUG` as a repository variable set to `true`. | `Changeset validation failed` | Changeset has validation errors | Review the validation errors in the step log | | `did not complete within N seconds` | Task polling timed out | Increase `INPROD_POLLING_TIMEOUT_MINUTES` | | `Invalid changeset_variables format` | A line in `INPROD_CHANGESET_VARIABLES` has no `=` | Ensure every non-comment line is `KEY=VALUE` | +| `INPROD_FILES: malformed entry "..."` | A line isn't `VARNAME=path` | Ensure every non-comment line is `VARNAME=path` | +| `INPROD_FILES: invalid variable name "..."` | Name is too long, not a valid identifier, or reserved | Use a short identifier-style name that isn't a JS reserved word or `callback_url` | +| `INPROD_FILES: ... file not found` / `not readable` | Path is wrong or unreadable | Check the path is relative to the repo root and the file is committed and readable | +| `INPROD_FILES: "..." is not a valid variable — it is already declared as masked in ...` | An `INPROD_FILES` name collides with an existing masked variable in the changeset | Rename the `INPROD_FILES` variable, or remove/unmask the conflicting declaration | +| `Temp-file upload failed: ... exceeds the server size limit` | File is larger than the server's max upload size | Reduce the file size or ask your InProd admin about `CHANGESET_TEMP_FILE_MAX_BYTES` | +| `Temp-file upload failed for ...: HTTP ...` | Upload request failed (auth, 5xx, etc.) | Check `INPROD_API_KEY` and InProd service status | | Artifacts not available in next step | `artifacts:` block not declared | Add both `inprod-result.json` and `inprod-results.env` under `artifacts:` | diff --git a/docs/circleci.md b/docs/circleci.md index b29a315..a4999f8 100644 --- a/docs/circleci.md +++ b/docs/circleci.md @@ -67,6 +67,7 @@ All configuration uses `INPROD_*` environment variables. | `INPROD_EXECUTION_STRATEGY` | No | `"per_file"` | `"per_file"` or `"validate_first"` | | `INPROD_FAIL_FAST` | No | `"false"` | Stop on first failure | | `INPROD_CHANGESET_VARIABLES` | No | `""` | Newline-separated `KEY=VALUE` pairs to inject into changesets | +| `INPROD_FILES` | No | `""` | Newline-separated `VARNAME=path` pairs. Each file is uploaded to InProd's temp-file store and the returned signed URL is injected as a changeset variable | --- @@ -443,6 +444,35 @@ Reference other environment variables (including secrets from project settings) --- +## File Uploads + +Genesys Cloud file fields (`BowFileField`) need a URL the InProd server can fetch. If the file only +exists in your repository — a prompt `.wav`, an MoH file, an image — use `INPROD_FILES` to have +`run-changesets` upload it to InProd's ephemeral temp-file store and inject the returned signed URL +as a changeset variable. + +```yaml +- run: + name: Run InProd Changesets + command: npx --yes @inprod.io/run-changesets + environment: + INPROD_CHANGESET_FILE: changesets/queues.yaml + INPROD_ENVIRONMENT: Production + INPROD_FILES: | + MOH_URL=./assets/moh.wav + PROMPT_AUDIO_URL=./assets/greeting.wav +``` + +**Format:** One `VARNAME=path` pair per line, mirroring `INPROD_CHANGESET_VARIABLES`. Blank lines +and `#`-comments are ignored; paths are resolved relative to the repo root. `VARNAME` must be ≤ 40 +characters, a valid JavaScript identifier, not a reserved word or `callback_url`, and must not +already exist as a masked variable in the changeset — invalid names or masked-name collisions fail +before any upload. Every file is uploaded fresh on every run; the signed URL expires 24 hours after +upload and is never cached or reused. Reference the variable in your changeset with the `[?? ?? ]` +script tag, e.g. `file_url: '[?? MOH_URL ??]'`. + +--- + ## Debug Logging Set `INPROD_DEBUG: 'true'` in the `environment:` block or as a project environment variable to enable verbose output including API request and response details: @@ -473,5 +503,11 @@ Set `INPROD_DEBUG: 'true'` in the `environment:` block or as a project environme | `Changeset validation failed` | Changeset has validation errors | Review the validation errors in the step log | | `did not complete within N seconds` | Task polling timed out | Increase `INPROD_POLLING_TIMEOUT_MINUTES` | | `Invalid changeset_variables format` | A line in `INPROD_CHANGESET_VARIABLES` has no `=` | Ensure every non-comment line is `KEY=VALUE` | +| `INPROD_FILES: malformed entry "..."` | A line isn't `VARNAME=path` | Ensure every non-comment line is `VARNAME=path` | +| `INPROD_FILES: invalid variable name "..."` | Name is too long, not a valid identifier, or reserved | Use a short identifier-style name that isn't a JS reserved word or `callback_url` | +| `INPROD_FILES: ... file not found` / `not readable` | Path is wrong or unreadable | Check the path is relative to the repo root and the file is committed and readable | +| `INPROD_FILES: "..." is not a valid variable — it is already declared as masked in ...` | An `INPROD_FILES` name collides with an existing masked variable in the changeset | Rename the `INPROD_FILES` variable, or remove/unmask the conflicting declaration | +| `Temp-file upload failed: ... exceeds the server size limit` | File is larger than the server's max upload size | Reduce the file size or ask your InProd admin about `CHANGESET_TEMP_FILE_MAX_BYTES` | +| `Temp-file upload failed for ...: HTTP ...` | Upload request failed (auth, 5xx, etc.) | Check `INPROD_API_KEY` and InProd service status | | Output files not found in downstream job | Workspace not persisted | Add `persist_to_workspace` in the deploy job and `attach_workspace` in the downstream job | | Approval jobs not triggering | `type: approval` job missing in workflow | Add a `hold` job of `type: approval` between the stages | diff --git a/docs/jenkins.md b/docs/jenkins.md index f28765b..95e8745 100644 --- a/docs/jenkins.md +++ b/docs/jenkins.md @@ -91,6 +91,7 @@ All configuration uses `INPROD_*` environment variables. Set them in the pipelin | `INPROD_EXECUTION_STRATEGY` | No | `"per_file"` | `"per_file"` or `"validate_first"` | | `INPROD_FAIL_FAST` | No | `"false"` | Stop on first failure | | `INPROD_CHANGESET_VARIABLES` | No | `""` | Newline-separated `KEY=VALUE` pairs to inject into changesets | +| `INPROD_FILES` | No | `""` | Newline-separated `VARNAME=path` pairs. Each file is uploaded to InProd's temp-file store and the returned signed URL is injected as a changeset variable | --- @@ -420,6 +421,37 @@ API_ENDPOINT=https://api.example.com" --- +## File Uploads + +Genesys Cloud file fields (`BowFileField`) need a URL the InProd server can fetch. If the file only +exists in your repository — a prompt `.wav`, an MoH file, an image — use `INPROD_FILES` to have +`run-changesets` upload it to InProd's ephemeral temp-file store and inject the returned signed URL +as a changeset variable. + +```groovy +stages { + stage('Deploy') { + steps { + sh ''' + export INPROD_FILES="MOH_URL=./assets/moh.wav +PROMPT_AUDIO_URL=./assets/greeting.wav" + npx --yes @inprod.io/run-changesets + ''' + } + } +} +``` + +**Format:** One `VARNAME=path` pair per line, mirroring `INPROD_CHANGESET_VARIABLES`. Blank lines +and `#`-comments are ignored; paths are resolved relative to the workspace root. `VARNAME` must be +≤ 40 characters, a valid JavaScript identifier, not a reserved word or `callback_url`, and must not +already exist as a masked variable in the changeset — invalid names or masked-name collisions fail +before any upload. Every file is uploaded fresh on every run; the signed URL expires 24 hours after +upload and is never cached or reused. Reference the variable in your changeset with the `[?? ?? ]` +script tag, e.g. `file_url: '[?? MOH_URL ??]'`. + +--- + ## Debug Logging Set `INPROD_DEBUG=true` to enable verbose output including API request and response details: @@ -450,6 +482,12 @@ environment { | `Changeset validation failed` | Changeset has validation errors | Review the validation errors in the stage log | | `did not complete within N seconds` | Task polling timed out | Increase `INPROD_POLLING_TIMEOUT_MINUTES` | | `Invalid changeset_variables format` | A line in `INPROD_CHANGESET_VARIABLES` has no `=` | Ensure every non-comment line is `KEY=VALUE` | +| `INPROD_FILES: malformed entry "..."` | A line isn't `VARNAME=path` | Ensure every non-comment line is `VARNAME=path` | +| `INPROD_FILES: invalid variable name "..."` | Name is too long, not a valid identifier, or reserved | Use a short identifier-style name that isn't a JS reserved word or `callback_url` | +| `INPROD_FILES: ... file not found` / `not readable` | Path is wrong or unreadable | Check the path is relative to the workspace root and the file is committed and readable | +| `INPROD_FILES: "..." is not a valid variable — it is already declared as masked in ...` | An `INPROD_FILES` name collides with an existing masked variable in the changeset | Rename the `INPROD_FILES` variable, or remove/unmask the conflicting declaration | +| `Temp-file upload failed: ... exceeds the server size limit` | File is larger than the server's max upload size | Reduce the file size or ask your InProd admin about `CHANGESET_TEMP_FILE_MAX_BYTES` | +| `Temp-file upload failed for ...: HTTP ...` | Upload request failed (auth, 5xx, etc.) | Check `INPROD_API_KEY` and InProd service status | | `npx: command not found` | Node.js not on agent PATH | Install the NodeJS plugin and configure a Node.js tool, or use a Docker agent | | Credentials appear empty | Secret not of type `Secret text` | Use **Secret text** credential type in Jenkins Credentials store | | `archiveArtifacts` finds no files | Package exited before writing outputs | Check for earlier errors in the stage log; add `allowEmptyArchive: true` to prevent pipeline failure | diff --git a/package-lock.json b/package-lock.json index a23225b..44f453f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@inprod.io/run-changesets", - "version": "1.0.0-beta.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@inprod.io/run-changesets", - "version": "1.0.0-beta.1", + "version": "1.0.0", "license": "GPL-3.0", "dependencies": { "glob": "^10.0.0", @@ -54,7 +54,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1350,7 +1349,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1634,7 +1632,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2053,7 +2050,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", diff --git a/src/index.js b/src/index.js index 97dc0f6..9867932 100644 --- a/src/index.js +++ b/src/index.js @@ -145,6 +145,119 @@ function injectJsonVariables(content, changesetVariables) { return JSON.stringify(doc, null, 2); } +// ── INPROD_FILES: temp-file upload support ──────────────────────────────────── + +// Exact list from inprod-bow/src/changeset/utils/native2gcfg.py:30-36 (BAD_VARIABLES) +const BAD_VARIABLES = new Set([ + 'break', 'case', 'catch', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'finally', 'for', + 'function', 'if', 'in', 'instanceof', 'new', 'return', 'switch', 'this', 'throw', 'try', 'typeof', + 'var', 'void', 'while', 'with', 'class', 'const', 'enum', 'export', 'extends', 'import', 'super', + 'implements', 'interface', 'let', 'package', 'private', 'protected', 'public', 'static', + 'yield', 'null', 'true', 'false', 'NaN', 'Infinity', 'undefined', 'int', 'byte', 'char', + 'goto', 'long', 'final', 'float', 'short', 'double', 'native', 'throws', 'boolean', 'abstract', + 'volatile', 'transient', 'synchronized', +]); + +// Exact set from inprod-bow/src/changeset/models.py:237 (ChangeSet.RESERVED_VARIABLE_NAMES) +const RESERVED_VARIABLE_NAMES = new Set(['callback_url']); + +// Exact regex from inprod-bow/src/changeset/utils/native2gcfg.py:1857 (test_variable_name) +const VALID_VARIABLE_NAME_RE = /^[A-Za-z_]\w*$/; + +function assertValidVariableName(name) { + if (!name) { + throw new Error(`INPROD_FILES: invalid variable name "${name}" (must not be empty)`); + } + if (name.length > 40) { + throw new Error(`INPROD_FILES: invalid variable name "${name}" (must be 40 characters or fewer)`); + } + if (!VALID_VARIABLE_NAME_RE.test(name)) { + throw new Error(`INPROD_FILES: invalid variable name "${name}" (must start with a letter or underscore, followed by letters, digits, or underscores)`); + } + if (BAD_VARIABLES.has(name)) { + throw new Error(`INPROD_FILES: invalid variable name "${name}" (is a reserved JavaScript word)`); + } + if (RESERVED_VARIABLE_NAMES.has(name)) { + throw new Error(`INPROD_FILES: invalid variable name "${name}" ("${name}" is reserved by InProd)`); + } +} + +function parseInprodFiles(input) { + const trimmedInput = (input || '').trim(); + if (!trimmedInput) return []; + + const parsed = []; + const lines = trimmedInput.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eq = trimmed.indexOf('='); + if (eq < 1) { + throw new Error(`INPROD_FILES: malformed entry "${trimmed}" (expected VARNAME=path)`); + } + const name = trimmed.slice(0, eq).trim(); + const filePath = trimmed.slice(eq + 1).trim(); + if (!filePath) { + throw new Error(`INPROD_FILES: malformed entry "${trimmed}" (expected VARNAME=path)`); + } + parsed.push({ name, path: filePath }); + } + return parsed; +} + +async function uploadTempFile(filePath, baseUrl, apiKey) { + const endpoint = `${baseUrl}/api/v1/change-set/temp-file/`; + const fileName = path.basename(filePath); + + const buffer = fs.readFileSync(filePath); + const form = new FormData(); + form.append('file', new Blob([buffer]), fileName); + + let response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Api-Key ${apiKey}` + }, + body: form + }); + } catch (error) { + logError(`Network error connecting to InProd API: ${error.message}`); + debug(`Full error details: ${error.stack}`); + throw new Error(`Failed to connect to InProd API at ${endpoint}: ${error.message}`); + } + + if (response.status === 413) { + throw new Error(`Temp-file upload failed: ${fileName} (${filePath}) exceeds the server size limit`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Temp-file upload failed for ${fileName}: HTTP ${response.status} ${errorBody || response.statusText}`); + } + + const data = await response.json(); + // Never log `data.url` — it's a 24h bearer credential (see changeset-temp-file-requirements.md §6). + // file_name/expires_at are safe to log even in debug mode. + debug(`Temp-file upload succeeded: file_name=${data.file_name}, expires_at=${data.expires_at}`); + return data; +} + +async function uploadInprodFiles(resolvedFiles, baseUrl, apiKey) { + const vars = {}; + info(`Uploading ${resolvedFiles.length} file(s) to InProd temp-file store...`); + + for (const { name, path: filePath } of resolvedFiles) { + const result = await uploadTempFile(filePath, baseUrl, apiKey); + info(` Uploaded: ${result.file_name} → ${name}`); + vars[name] = result.url; + } + + return vars; +} + // ── File format detection ───────────────────────────────────────────────────── function getFileFormat(filePath) { @@ -431,6 +544,7 @@ async function run() { const executionStrategy = (process.env.INPROD_EXECUTION_STRATEGY || 'per_file').trim(); const failFast = (process.env.INPROD_FAIL_FAST || 'false') === 'true'; const changesetVariablesInput = process.env.INPROD_CHANGESET_VARIABLES || ''; + const inprodFilesInput = process.env.INPROD_FILES || ''; // Parse changeset variables from KEY=VALUE format let changesetVariables = null; @@ -449,6 +563,9 @@ async function run() { debug(`Parsed changeset variables: ${JSON.stringify(Object.keys(changesetVariables))} (values masked)`); } + // Parse INPROD_FILES (pure — no filesystem/network access yet) + const parsedInprodFiles = parseInprodFiles(inprodFilesInput); + // Validate required inputs if (!apiKey) { throw new Error('api_key is required and cannot be empty'); @@ -464,9 +581,60 @@ async function run() { throw new Error(`Invalid base_url format: ${baseUrl}`); } + // Resolve every INPROD_FILES path once (absolute, relative to cwd — same convention + // as resolveFiles()), then validate before any HTTP call. The resolved path is reused + // for the upload itself so a later upload-error message can never disagree with the + // "file not found"/"not readable" message for the same file. + const resolvedInprodFiles = parsedInprodFiles.map(({ name, path: rawPath }) => ({ + name, + path: path.resolve(rawPath), + })); + const fileVarNames = new Set(); + for (const { name, path: resolvedPath } of resolvedInprodFiles) { + assertValidVariableName(name); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`INPROD_FILES: ${name} → file not found: ${resolvedPath}`); + } + try { + fs.accessSync(resolvedPath, fs.constants.R_OK); + } catch (e) { + throw new Error(`INPROD_FILES: ${name} → file not readable: ${resolvedPath}`); + } + fileVarNames.add(name); + } + // Resolve changeset files (also validates changeset_file is provided) const filePaths = resolveFiles(changesetFile); + // Reject INPROD_FILES names that collide with an already-masked variable in any + // changeset file about to be processed. File-URL variables can never work while + // masked (stripped from the JS scope before [?? ?? ] is evaluated server-side), so + // this fails fast with a clear message instead of silently overriding. Still zero + // HTTP calls at this point. Spans every resolved file (glob-aware) since + // changesetVariables is injected uniformly into all of them. + if (fileVarNames.size > 0) { + for (const fp of filePaths) { + const content = fs.readFileSync(fp, 'utf8'); + const format = getFileFormat(fp); + const doc = format === 'json' ? JSON.parse(content) : yaml.load(content); + const existingVars = Array.isArray(doc.variable) ? doc.variable : []; + for (const v of existingVars) { + if (fileVarNames.has(v.name) && v.mask_value === true) { + throw new Error( + `INPROD_FILES: "${v.name}" is not a valid variable — it is already declared as masked in ${path.basename(fp)}` + ); + } + } + } + } + + // Upload INPROD_FILES and merge the returned signed URLs into changesetVariables. + // Always re-uploaded on every invocation — signed URLs are never cached/reused. + if (parsedInprodFiles.length > 0) { + const uploadedVars = await uploadInprodFiles(resolvedInprodFiles, baseUrl, apiKey); + changesetVariables = { ...(changesetVariables || {}), ...uploadedVars }; + } + const options = { apiKey, baseUrl, environment, validateBeforeExecute, validateOnly, pollingTimeoutSeconds, changesetVariables, @@ -486,6 +654,9 @@ async function run() { if (changesetVariables) { info(`Changeset variables: ${Object.keys(changesetVariables).length} variable(s) provided`); } + if (parsedInprodFiles.length > 0) { + info(`Files to upload: ${parsedInprodFiles.length} file(s) via INPROD_FILES`); + } const results = []; @@ -609,7 +780,11 @@ async function run() { } } -module.exports = { run, pollTask, buildUrl, isGlobPattern, resolveFiles, worstStatus, getFileFormat, injectYamlVariables, injectJsonVariables }; +module.exports = { + run, pollTask, buildUrl, isGlobPattern, resolveFiles, worstStatus, getFileFormat, + injectYamlVariables, injectJsonVariables, + parseInprodFiles, assertValidVariableName, uploadTempFile, uploadInprodFiles, +}; /* istanbul ignore next */ if (require.main === module) { diff --git a/src/index.test.js b/src/index.test.js index 64e4b2d..b470dc2 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -14,7 +14,11 @@ let mockConsoleError; let mockConsoleWarn; let mockFsWriteFileSync; -const { run, pollTask, buildUrl, isGlobPattern, resolveFiles, worstStatus, getFileFormat, injectYamlVariables, injectJsonVariables } = require('./index'); +const { + run, pollTask, buildUrl, isGlobPattern, resolveFiles, worstStatus, getFileFormat, + injectYamlVariables, injectJsonVariables, + parseInprodFiles, assertValidVariableName, uploadTempFile, uploadInprodFiles, +} = require('./index'); // ── Environment variable mapping ───────────────────────────────────────────── @@ -23,6 +27,7 @@ const ALL_ENV_VARS = [ 'INPROD_ENVIRONMENT', 'INPROD_VALIDATE_BEFORE_EXECUTE', 'INPROD_VALIDATE_ONLY', 'INPROD_POLLING_TIMEOUT_MINUTES', 'INPROD_EXECUTION_STRATEGY', 'INPROD_FAIL_FAST', 'INPROD_CHANGESET_VARIABLES', 'INPROD_DEBUG', + 'INPROD_FILES', ]; const INPUT_TO_ENV = { @@ -36,6 +41,7 @@ const INPUT_TO_ENV = { execution_strategy: 'INPROD_EXECUTION_STRATEGY', fail_fast: 'INPROD_FAIL_FAST', changeset_variables: 'INPROD_CHANGESET_VARIABLES', + files: 'INPROD_FILES', }; function mockInputs(inputs) { @@ -146,6 +152,20 @@ const GLOB_FILE_01 = path.join(__dirname, '__test_01_queues__.yaml'); const GLOB_FILE_02 = path.join(__dirname, '__test_02_flows__.yaml'); const GLOB_FILE_03 = path.join(__dirname, '__test_03_webchat__.yaml'); +// Fixture file for INPROD_FILES upload tests +const UPLOAD_FIXTURE_FILE = path.join(__dirname, '__test_upload_fixture__.wav'); + +// Changeset fixture with a pre-existing masked variable, for the masked-conflict test +const MASKED_CHANGESET_FILE = path.join(__dirname, '__test_masked_moh__.yaml'); +const MASKED_CHANGESET = `name: Test Queue +environment: Development +variable: +- environment: null + mask_value: true + name: MOH_URL + value: placeholder +action: []`; + // Helper to build expected result array for a single file function singleExpectedResult(status, result, error = null) { return [{ @@ -161,10 +181,12 @@ beforeAll(() => { fs.writeFileSync(GLOB_FILE_01, SAMPLE_CHANGESET); fs.writeFileSync(GLOB_FILE_02, SAMPLE_CHANGESET); fs.writeFileSync(GLOB_FILE_03, SAMPLE_CHANGESET); + fs.writeFileSync(UPLOAD_FIXTURE_FILE, Buffer.from('fake-audio-bytes')); + fs.writeFileSync(MASKED_CHANGESET_FILE, MASKED_CHANGESET); }); afterAll(() => { - [SAMPLE_CHANGESET_FILE, GLOB_FILE_01, GLOB_FILE_02, GLOB_FILE_03].forEach(f => { + [SAMPLE_CHANGESET_FILE, GLOB_FILE_01, GLOB_FILE_02, GLOB_FILE_03, UPLOAD_FIXTURE_FILE, MASKED_CHANGESET_FILE].forEach(f => { if (fs.existsSync(f)) fs.unlinkSync(f); }); }); @@ -1592,6 +1614,175 @@ describe('run — changeset variables', () => { }); }); +// ─── run() — INPROD_FILES ────────────────────────────────────────────────────── +// +// NOTE: INPROD_VALIDATE_BEFORE_EXECUTE defaults to true, meaning the default per_file +// strategy calls validateFile (its own POST to validate_yaml + its own poll) BEFORE +// executeFile (POST to execute_yaml + poll). Every test below pins +// validate_before_execute: 'false' so it only exercises the new upload behavior plus a +// single execute+poll round trip, matching how 'run — changeset variables' above does +// the same thing via baseInputs. + +describe('run — INPROD_FILES', () => { + const baseInputs = { + api_key: 'key', + base_url: 'https://test.inprod.io', + changeset_file: SAMPLE_CHANGESET_FILE, + validate_before_execute: 'false', + }; + + test('uploads file, injects unmasked URL variable, then executes', async () => { + mockInputs({ ...baseInputs, files: `MOH_URL=${UPLOAD_FIXTURE_FILE}` }); + + mockFetch + .mockResolvedValueOnce(mockFetchResponse(201, { + url: 'https://signed.example.com/moh.wav', + file_name: 'moh.wav', + expires_at: '2026-07-21T00:00:00Z', + })) + .mockResolvedValueOnce(mockFetchResponse(200, executeTaskResponse())) + .mockResolvedValueOnce(mockFetchResponse(200, successPollResponse({}))); + + const promise = run(); + await jest.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockFetch).toHaveBeenCalledTimes(3); // upload + execute POST + poll — no validate step + const executeCall = mockFetch.mock.calls.find(call => String(call[0]).includes('execute_yaml')); + expect(executeCall[1].body).toContain('name: MOH_URL'); + expect(executeCall[1].body).toContain('value: https://signed.example.com/moh.wav'); + expect(executeCall[1].body).toContain('mask_value: false'); + expect(getWrittenStatus()).toBe('SUCCESS'); + + // At default verbosity (INPROD_DEBUG unset), the signed URL must never appear in + // console output — only in the outbound HTTP request body, which is expected. + const allLoggedText = [ + ...mockConsoleLog.mock.calls, ...mockConsoleError.mock.calls, ...mockConsoleWarn.mock.calls, + ].flat().map(String).join('\n'); + expect(allLoggedText).not.toContain('https://signed.example.com/moh.wav'); + }); + + test('rejects with zero HTTP calls when the changeset already declares that name as masked', async () => { + mockInputs({ ...baseInputs, changeset_file: MASKED_CHANGESET_FILE, files: `MOH_URL=${UPLOAD_FIXTURE_FILE}` }); + + await run(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('is not a valid variable') + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('already declared as masked') + ); + }); + + test('missing file fails fast with zero HTTP calls', async () => { + mockInputs({ ...baseInputs, files: 'MOH_URL=./does/not/exist.wav' }); + + await run(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('INPROD_FILES: MOH_URL') + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('file not found') + ); + }); + + test('invalid variable name fails fast with zero HTTP calls', async () => { + mockInputs({ ...baseInputs, files: `class=${UPLOAD_FIXTURE_FILE}` }); + + await run(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('invalid variable name') + ); + }); + + test('malformed entry fails fast with zero HTTP calls', async () => { + mockInputs({ ...baseInputs, files: 'NOT_A_VALID_ENTRY' }); + + await run(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('malformed entry') + ); + }); + + test('413 upload response aborts before execute is attempted', async () => { + mockInputs({ ...baseInputs, files: `MOH_URL=${UPLOAD_FIXTURE_FILE}` }); + mockFetch.mockResolvedValueOnce(mockFetchResponse(413, { errors: { base: ['File exceeds maximum size.'] } }, false)); + + await run(); + + expect(mockFetch).toHaveBeenCalledTimes(1); // only the failed upload — execute never attempted + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('exceeds the server size limit') + ); + }); + + test('network error during upload aborts before execute is attempted', async () => { + mockInputs({ ...baseInputs, files: `MOH_URL=${UPLOAD_FIXTURE_FILE}` }); + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await run(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Failed to connect to InProd API') + ); + }); + + test('INPROD_FILES unset is a byte-identical no-op (regression guard)', async () => { + mockInputs(baseInputs); // no `files` key — matches a pre-feature baseline run + + mockFetch + .mockResolvedValueOnce(mockFetchResponse(200, executeTaskResponse())) + .mockResolvedValueOnce(mockFetchResponse(200, successPollResponse({}))); + + const promise = run(); + await jest.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockFetch).toHaveBeenCalledTimes(2); // execute + poll only, no upload call + expect(getWrittenStatus()).toBe('SUCCESS'); + }); + + test('never logs the signed URL, at default verbosity or with INPROD_DEBUG=true', async () => { + mockInputs({ ...baseInputs, files: `MOH_URL=${UPLOAD_FIXTURE_FILE}` }); + process.env.INPROD_DEBUG = 'true'; + + mockFetch + .mockResolvedValueOnce(mockFetchResponse(201, { + url: 'https://signed.example.com/super-secret-path', + file_name: 'moh.wav', + expires_at: '2026-07-21T00:00:00Z', + })) + .mockResolvedValueOnce(mockFetchResponse(200, executeTaskResponse())) + .mockResolvedValueOnce(mockFetchResponse(200, successPollResponse({}))); + + const promise = run(); + await jest.advanceTimersByTimeAsync(5000); + await promise; + + const allLoggedText = [ + ...mockConsoleLog.mock.calls, ...mockConsoleError.mock.calls, ...mockConsoleWarn.mock.calls, + ].flat().map(String).join('\n'); + expect(allLoggedText).not.toContain('https://signed.example.com/super-secret-path'); + + delete process.env.INPROD_DEBUG; + }); +}); + // ─── getFileFormat ──────────────────────────────────────────────────────────── describe('getFileFormat', () => { @@ -1892,3 +2083,150 @@ describe('injectJsonVariables', () => { expect(result.action).toEqual([{ action: 'gencloud-create' }]); }); }); + +// ─── parseInprodFiles ─────────────────────────────────────────────────────────── + +describe('parseInprodFiles', () => { + test('parses multiple VARNAME=path lines', () => { + expect(parseInprodFiles('MOH_URL=./assets/moh.wav\nGREETING_URL=./assets/greeting.wav')).toEqual([ + { name: 'MOH_URL', path: './assets/moh.wav' }, + { name: 'GREETING_URL', path: './assets/greeting.wav' }, + ]); + }); + + test('ignores blank lines and comments', () => { + expect(parseInprodFiles('\nMOH_URL=./assets/moh.wav\n\n# a comment\n')) + .toEqual([{ name: 'MOH_URL', path: './assets/moh.wav' }]); + }); + + test('splits on the first = only, preserving = in the path', () => { + expect(parseInprodFiles('MOH_URL=./assets/moh?sig=abc=def.wav')) + .toEqual([{ name: 'MOH_URL', path: './assets/moh?sig=abc=def.wav' }]); + }); + + test('trims whitespace from name and path', () => { + expect(parseInprodFiles(' MOH_URL = ./assets/moh.wav ')) + .toEqual([{ name: 'MOH_URL', path: './assets/moh.wav' }]); + }); + + test('throws on a malformed entry with no =', () => { + expect(() => parseInprodFiles('MOH_URL')).toThrow(/malformed entry/); + }); + + test('throws on an entry with = at index 0 (empty name)', () => { + expect(() => parseInprodFiles('=./assets/moh.wav')).toThrow(/malformed entry/); + }); + + test('throws on an entry with an empty path after =', () => { + expect(() => parseInprodFiles('MOH_URL=')).toThrow(/malformed entry/); + }); + + test('returns [] for empty/unset input', () => { + expect(parseInprodFiles('')).toEqual([]); + expect(parseInprodFiles(' \n ')).toEqual([]); + expect(parseInprodFiles(undefined)).toEqual([]); + }); +}); + +// ─── assertValidVariableName ───────────────────────────────────────────────────── + +describe('assertValidVariableName', () => { + test('accepts a valid name', () => { + expect(() => assertValidVariableName('MOH_URL')).not.toThrow(); + expect(() => assertValidVariableName('_leadingUnderscore')).not.toThrow(); + }); + + test('rejects an empty name', () => { + expect(() => assertValidVariableName('')).toThrow(/must not be empty/); + }); + + test('rejects names over 40 characters', () => { + expect(() => assertValidVariableName('A'.repeat(41))).toThrow(/40 characters/); + }); + + test('accepts a name that is exactly 40 characters', () => { + expect(() => assertValidVariableName('A'.repeat(40))).not.toThrow(); + }); + + test('rejects invalid identifiers', () => { + expect(() => assertValidVariableName('1BAD')).toThrow(/must start with/); + expect(() => assertValidVariableName('BAD-NAME')).toThrow(/must start with/); + expect(() => assertValidVariableName('BAD$NAME')).toThrow(/must start with/); + }); + + test('rejects JS reserved words', () => { + expect(() => assertValidVariableName('class')).toThrow(/reserved JavaScript word/); + expect(() => assertValidVariableName('return')).toThrow(/reserved JavaScript word/); + expect(() => assertValidVariableName('null')).toThrow(/reserved JavaScript word/); + }); + + test('rejects callback_url', () => { + expect(() => assertValidVariableName('callback_url')).toThrow(/reserved by InProd/); + }); +}); + +// ─── uploadTempFile / uploadInprodFiles ────────────────────────────────────────── + +describe('uploadTempFile', () => { + test('posts multipart with Authorization header and field name "file"', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse(201, { + url: 'https://storage.example.com/signed-url', + file_name: 'sample.wav', + expires_at: '2026-07-21T00:00:00Z', + })); + + const result = await uploadTempFile(UPLOAD_FIXTURE_FILE, 'https://tenant1.inprod.io', 'test-key'); + + const [calledUrl, callArgs] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe('https://tenant1.inprod.io/api/v1/change-set/temp-file/'); + expect(callArgs.method).toBe('POST'); + expect(callArgs.headers).toEqual({ 'Authorization': 'Api-Key test-key' }); + expect(callArgs.body.get('file')).toBeTruthy(); + expect(result.url).toBe('https://storage.example.com/signed-url'); + }); + + test('413 response surfaces a size-limit error', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse(413, { errors: { base: ['File exceeds maximum size.'] } }, false)); + await expect(uploadTempFile(UPLOAD_FIXTURE_FILE, 'https://tenant1.inprod.io', 'test-key')) + .rejects.toThrow(/exceeds the server size limit/); + }); + + test('other non-ok response surfaces a generic upload-failed error', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse(500, 'Internal Server Error', false)); + await expect(uploadTempFile(UPLOAD_FIXTURE_FILE, 'https://tenant1.inprod.io', 'test-key')) + .rejects.toThrow(/Temp-file upload failed for/); + }); + + test('network error is wrapped with a connection-failure message', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + await expect(uploadTempFile(UPLOAD_FIXTURE_FILE, 'https://tenant1.inprod.io', 'test-key')) + .rejects.toThrow(/Failed to connect to InProd API/); + }); +}); + +describe('uploadInprodFiles', () => { + test('uploads each entry and returns a VARNAME -> url map', async () => { + mockFetch + .mockResolvedValueOnce(mockFetchResponse(201, { url: 'https://storage.example.com/1', file_name: 'a.wav', expires_at: '2026-07-21T00:00:00Z' })) + .mockResolvedValueOnce(mockFetchResponse(201, { url: 'https://storage.example.com/2', file_name: 'b.wav', expires_at: '2026-07-21T00:00:00Z' })); + + const result = await uploadInprodFiles([ + { name: 'VAR_A', path: UPLOAD_FIXTURE_FILE }, + { name: 'VAR_B', path: UPLOAD_FIXTURE_FILE }, + ], 'https://tenant1.inprod.io', 'test-key'); + + expect(result).toEqual({ VAR_A: 'https://storage.example.com/1', VAR_B: 'https://storage.example.com/2' }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('throws immediately on the first failure without uploading remaining entries', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse(413, { errors: { base: ['File exceeds maximum size.'] } }, false)); + + await expect(uploadInprodFiles([ + { name: 'VAR_A', path: UPLOAD_FIXTURE_FILE }, + { name: 'VAR_B', path: UPLOAD_FIXTURE_FILE }, + ], 'https://tenant1.inprod.io', 'test-key')).rejects.toThrow(/exceeds the server size limit/); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +});