From aae431e71b52f2fe7fe6dc3cef71e345882ce210 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 13:58:06 +0300 Subject: [PATCH 1/6] fix: support shrunk client payloads on v1 client_payload arrives as plain JSON, base64(gzip), or a reference to a server-stashed payload. v1 resolved fields with fromJSON(fromJSON(github.event.inputs.client_payload)) in five places, which throws on the last two shapes before the step can run. Resolve those fields in a step instead, following the existing script.js idiom on this branch - a root-level module exported as a function of core, required with github.action_path. Only the five fields v1's action.yml actually consumes are emitted; v1 has no cm org checkout. CLIENT_PAYLOAD is still handed to the docker rules engine untouched. The engine resolves the envelope itself (gitstream/rules-engine:latest ships gitstream-core 2.1.301, which has that support), and passing the inflated payload through the runner env would risk E2BIG on large payloads. The deprecation notice, docker cache/pull/load steps and the engine invocation are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 22 ++++-- resolve-payload-fields.js | 157 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 resolve-payload-fields.js diff --git a/action.yml b/action.yml index 51dfef22..f97f222c 100644 --- a/action.yml +++ b/action.yml @@ -35,6 +35,18 @@ runs: - name: Deprecation Notice shell: bash run: echo "::warning::gitstream-github-action@v1 is deprecated and will be disabled soon. Please upgrade to gitstream-github-action@v2. For more information, follow https://github.com/linear-b/gitstream-github-action/releases/tag/v2" + + # client_payload arrives as plain JSON, base64(gzip), or a reference to a server-stashed + # payload. See resolve-payload-fields.js for the resolution logic and its outputs. + - name: Resolve payload fields + id: payload-fields + uses: actions/github-script@v7 + env: + PAYLOAD_ARG: ${{ github.event.inputs.client_payload }} + RESOLVER_URL_ARG: ${{ github.event.inputs.resolver_url }} + with: + script: | + await require('${{ github.action_path }}/resolve-payload-fields.js')(core); - name: Create GitStream folder shell: bash run: | @@ -49,7 +61,7 @@ runs: repository: ${{ inputs.full_repository }} ref: ${{ github.event.inputs.base_ref }} path: "gitstream/repo/" - token: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).githubToken || github.token }} + token: ${{ steps.payload-fields.outputs.github_token || github.token }} - name: Escape single quotes id: safe-strings @@ -58,7 +70,7 @@ runs: BASE_REF_ARG: ${{ github.event.inputs.base_ref }} HEAD_REF_ARG: ${{ github.event.inputs.head_ref }} PAYLOAD_ARG: ${{ github.event.inputs.client_payload }} - URL_ARG: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).headHttpUrl || fromJSON(fromJSON(github.event.inputs.client_payload)).repoUrl }} + URL_ARG: ${{ steps.payload-fields.outputs.url }} with: script: | try { @@ -100,10 +112,10 @@ runs: - name: Checkout cm repo uses: actions/checkout@v4 - if: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).hasCmRepo == true }} + if: ${{ steps.payload-fields.outputs.has_cm_repo == 'true' }} with: - repository: "${{ fromJSON(fromJSON(github.event.inputs.client_payload)).owner }}/${{ fromJSON(fromJSON(github.event.inputs.client_payload)).cmRepo }}" - ref: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).cmRepoRef }} + repository: ${{ steps.payload-fields.outputs.cm_repository }} + ref: ${{ steps.payload-fields.outputs.cm_repo_ref }} path: "gitstream/cm/" - name: Get Docker cache key diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js new file mode 100644 index 00000000..51f1d0d0 --- /dev/null +++ b/resolve-payload-fields.js @@ -0,0 +1,157 @@ +/** + * Resolves the `client_payload` input of action.yml into the individual fields + * that later steps consume. + * + * The payload reaches the action in one of three shapes: + * - plain JSON (possibly double-encoded as a JSON string) + * - compressed base64(gzip(JSON)) + * - reference small JSON pointing at a payload stashed on the resolver, + * used when the payload is too large to pass through GitHub + * + * Only the fields needed by YAML step expressions are resolved here. + * CLIENT_PAYLOAD itself is passed to the rules engine untouched - the engine + * inflates it, which keeps the large inflated payload off the runner's env. + */ + +const { gunzipSync } = require('zlib'); + +const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'; +const PAYLOAD_FETCH_TIMEOUT_MS = 10000; + +// Bounds a decompression bomb: gzip is asymmetric, so a small input can inflate +// far enough to exhaust the runner. +const MAX_INFLATED_PAYLOAD_BYTES = 32 * 1024 * 1024; + +function inflateIfGzipped(value) { + const buffer = Buffer.from(value, 'base64'); + const isGzip = buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b; + if (!isGzip) { + return null; + } + try { + return gunzipSync(buffer, { + maxOutputLength: MAX_INFLATED_PAYLOAD_BYTES, + }).toString('utf8'); + } catch (err) { + if (err.code === 'ERR_BUFFER_TOO_LARGE') { + throw new Error( + `payload inflates beyond ${MAX_INFLATED_PAYLOAD_BYTES} bytes; refusing to expand it`, + ); + } + throw new Error(`gzip decompression failed: ${err.message}`); + } +} + +// Parses JSON that may have been encoded twice. +function parsePayload(value) { + const parsed = JSON.parse(value); + return typeof parsed === 'string' ? JSON.parse(parsed) : parsed; +} + +function readStashReference(raw) { + // Cheap pre-check so a regular payload is only parsed once, further down. + if (!raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { + return null; + } + const parsed = parsePayload(raw); + return parsed && parsed.type === OVERSIZED_PAYLOAD_REFERENCE ? parsed : null; +} + +/** + * Builds the stash URL on the resolver's own origin. + * + * The reference names a URL to fetch, but it arrives inside client_payload, so + * that URL is attacker-influenced. Take only its path and query and re-attach + * them to resolver_url, so the host that reaches the network comes from the + * action's own input and cannot be steered at an internal address. The path is + * applied via the `pathname` setter: resolving it as a relative URL would let a + * `//host/...` path escape to another origin. + */ +function stashUrl(payloadUrl, resolverUrl) { + if (!resolverUrl) { + throw new Error( + 'resolver_url is not set; cannot validate the stashed payload origin', + ); + } + const resolverOrigin = new URL(resolverUrl).origin; + const requested = new URL(payloadUrl); + if (requested.origin !== resolverOrigin) { + throw new Error( + `refusing to fetch stashed payload from ${requested.origin}; expected ${resolverOrigin}`, + ); + } + const url = new URL(resolverOrigin); + url.pathname = requested.pathname; + url.search = requested.search; + return url; +} + +async function fetchStashedPayload(reference, resolverUrl, core) { + const url = stashUrl(reference.payloadUrl, resolverUrl); + core.setSecret(reference.resolverToken); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${reference.resolverToken}` }, + signal: AbortSignal.timeout(PAYLOAD_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`stashed payload fetch returned ${response.status}`); + } + const body = await response.text(); + return parsePayload(inflateIfGzipped(body) ?? body); +} + +async function resolvePayload(raw, resolverUrl, core) { + const reference = readStashReference(raw); + if (reference) { + const payload = await fetchStashedPayload(reference, resolverUrl, core); + return { mode: 'reference', payload }; + } + const inflated = inflateIfGzipped(raw); + if (inflated !== null) { + return { mode: 'compressed', payload: parsePayload(inflated) }; + } + return { mode: 'plain', payload: parsePayload(raw) }; +} + +/** + * Maps a resolved payload to the step outputs v1's action.yml consumes. Output + * values are strings, so booleans are stringified to be compared as `== 'true'` + * in step conditions. + */ +function toStepOutputs(payload) { + const hasCmRepo = payload.hasCmRepo === true; + return { + github_token: payload.githubToken || '', + url: payload.headHttpUrl || payload.repoUrl || '', + has_cm_repo: String(hasCmRepo), + cm_repository: hasCmRepo ? `${payload.owner}/${payload.cmRepo}` : '', + cm_repo_ref: payload.cmRepoRef || '', + }; +} + +module.exports = async core => { + const { PAYLOAD_ARG, RESOLVER_URL_ARG } = process.env; + + try { + const { mode, payload } = await resolvePayload( + PAYLOAD_ARG || '', + RESOLVER_URL_ARG, + core, + ); + core.info(`client_payload mode=${mode}`); + + const outputs = toStepOutputs(payload); + // The installation token rides inside client_payload, so mask it before it + // reaches an output or a later step's env dump. + if (outputs.github_token) { + core.setSecret(outputs.github_token); + } + for (const [name, value] of Object.entries(outputs)) { + core.setOutput(name, value); + } + } catch (err) { + core.setFailed(`Failed resolving client payload: ${err}`); + } +}; + +module.exports.toStepOutputs = toStepOutputs; From a527e6df6ebfd996af23ee22a0a4c9883e01c0c1 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 14:22:31 +0300 Subject: [PATCH 2/6] fix: read the wrapped compressed-payload envelope Same resolver change as develop and v2-lite: parse first and switch on the value of `type`, so the double-encoded compressed-payload envelope the GitHub trigger will send is inflated instead of being mistaken for a raw payload and silently resolving to empty fields. Both compressed forms stay supported permanently - Bitbucket has no run-name to protect and keeps sending the bare base64(gzip) form. Co-Authored-By: Claude Opus 5 (1M context) --- resolve-payload-fields.js | 42 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index 51f1d0d0..1b645a6d 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -16,6 +16,7 @@ const { gunzipSync } = require('zlib'); const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'; +const COMPRESSED_PAYLOAD = 'compressed-payload'; const PAYLOAD_FETCH_TIMEOUT_MS = 10000; // Bounds a decompression bomb: gzip is asymmetric, so a small input can inflate @@ -48,13 +49,15 @@ function parsePayload(value) { return typeof parsed === 'string' ? JSON.parse(parsed) : parsed; } -function readStashReference(raw) { - // Cheap pre-check so a regular payload is only parsed once, further down. - if (!raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { +// Returns the parsed value, or null when `raw` is not JSON at all - the bare +// base64(gzip) form, which has no envelope around it. +function tryParsePayload(raw) { + try { + const parsed = parsePayload(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { return null; } - const parsed = parsePayload(raw); - return parsed && parsed.type === OVERSIZED_PAYLOAD_REFERENCE ? parsed : null; } /** @@ -100,16 +103,37 @@ async function fetchStashedPayload(reference, resolverUrl, core) { return parsePayload(inflateIfGzipped(body) ?? body); } +/** + * Resolves whichever shape the trigger sent. Both compressed forms are + * permanent, not a migration step: GitHub wraps the payload in an envelope so + * that `run-name`, which is evaluated before any step exists and so cannot be + * rescued from here, still parses. Bitbucket has no `run-name` and keeps + * sending the bare form. + */ async function resolvePayload(raw, resolverUrl, core) { - const reference = readStashReference(raw); - if (reference) { - const payload = await fetchStashedPayload(reference, resolverUrl, core); - return { mode: 'reference', payload }; + const parsed = tryParsePayload(raw); + if (parsed) { + // Switch on the *value* of `type`, never its presence: a raw payload may + // legitimately carry its own `type` (Bitbucket builds it from the webhook + // context), and must fall through to the raw branch below. + if (parsed.type === OVERSIZED_PAYLOAD_REFERENCE) { + const payload = await fetchStashedPayload(parsed, resolverUrl, core); + return { mode: 'reference', payload }; + } + if (parsed.type === COMPRESSED_PAYLOAD) { + const inflated = inflateIfGzipped(parsed.data || ''); + if (inflated === null) { + throw new Error(`${COMPRESSED_PAYLOAD} envelope carries no gzip data`); + } + return { mode: 'compressed-envelope', payload: parsePayload(inflated) }; + } + return { mode: 'plain', payload: parsed }; } const inflated = inflateIfGzipped(raw); if (inflated !== null) { return { mode: 'compressed', payload: parsePayload(inflated) }; } + // Not JSON and not gzip - let the JSON error describe what arrived. return { mode: 'plain', payload: parsePayload(raw) }; } From 595d75bffc0dcce400adf85162365912437b8bb8 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 14:30:36 +0300 Subject: [PATCH 3/6] fix: name the offending URL when payloadUrl is not absolute Same diagnostic as develop and v2-lite. Co-Authored-By: Claude Opus 5 (1M context) --- resolve-payload-fields.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index 1b645a6d..dc720198 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -64,11 +64,16 @@ function tryParsePayload(raw) { * Builds the stash URL on the resolver's own origin. * * The reference names a URL to fetch, but it arrives inside client_payload, so - * that URL is attacker-influenced. Take only its path and query and re-attach - * them to resolver_url, so the host that reaches the network comes from the - * action's own input and cannot be steered at an internal address. The path is - * applied via the `pathname` setter: resolving it as a relative URL would let a - * `//host/...` path escape to another origin. + * that URL is attacker-influenced. The host in `payloadUrl` is decorative and + * is discarded - always, not only when it disagrees. Only the path and query + * are carried over, re-attached to resolver_url, which comes from the workflow + * rather than the payload. That makes this structurally immune to being + * redirected through this field, so please do not "fix" it later by honouring + * the payload's host. + * + * The path is applied via the `pathname` setter rather than by resolving it as + * a relative URL: relative resolution would let a `//host/...` path escape to + * another origin. */ function stashUrl(payloadUrl, resolverUrl) { if (!resolverUrl) { @@ -77,7 +82,16 @@ function stashUrl(payloadUrl, resolverUrl) { ); } const resolverOrigin = new URL(resolverUrl).origin; - const requested = new URL(payloadUrl); + let requested; + try { + // The trigger always sends an absolute URL; both it and resolver_url are + // built from the same base, so a relative one means that base was empty. + requested = new URL(payloadUrl); + } catch { + throw new Error( + `stashed payload URL is not absolute: ${payloadUrl} - the resolver's public API base is probably unset`, + ); + } if (requested.origin !== resolverOrigin) { throw new Error( `refusing to fetch stashed payload from ${requested.origin}; expected ${resolverOrigin}`, From dbf4ecdae25ab1f22508f19180fb68242be0942a Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 18:36:51 +0300 Subject: [PATCH 4/6] fix: hand the engine the payload shape it resolves #568's port resolved the YAML field expressions but passed CLIENT_PAYLOAD to the docker engine untouched. The trigger double-encodes every tier so the workflow's run-name can parse it, and gitstream-core does a single JSON.parse on a reference - so a double-encoded envelope resolves to a string, the reference is never followed, and the engine treats the envelope as the payload. Stripping the outer layer here lets both consumers read the same dispatch, without changing core or the trigger. Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 4 +++- resolve-payload-fields.js | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index f97f222c..83a15dc3 100644 --- a/action.yml +++ b/action.yml @@ -69,7 +69,9 @@ runs: env: BASE_REF_ARG: ${{ github.event.inputs.base_ref }} HEAD_REF_ARG: ${{ github.event.inputs.head_ref }} - PAYLOAD_ARG: ${{ github.event.inputs.client_payload }} + # Normalized rather than the raw input: the trigger double-encodes every tier so `run-name` + # can parse it, and the engine reads the same value expecting its own shapes. + PAYLOAD_ARG: ${{ steps.payload-fields.outputs.client_payload }} URL_ARG: ${{ steps.payload-fields.outputs.url }} with: script: | diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index dc720198..01c6ec82 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -156,6 +156,36 @@ async function resolvePayload(raw, resolverUrl, core) { * values are strings, so booleans are stringified to be compared as `== 'true'` * in step conditions. */ +/** + * What the docker rules engine receives as CLIENT_PAYLOAD. + * + * Two consumers read the same dispatch input and need different shapes. The workflow's `run-name` is + * evaluated before any step exists, and the template we publish does + * `fromJSON(fromJSON(client_payload))`, so the trigger double-encodes every tier to keep that + * resolving to an object. gitstream-core resolves the payload itself and expects the shapes it has + * always taken: one JSON.parse for the reference, gzip magic bytes for the compressed form. Stripping + * the outer layer here is what lets both read the same dispatch. + * + * Only the outer layer comes off - the payload a reference points at is never fetched here. + * + * @param {string} raw the `client_payload` input, verbatim + * @returns {string} the form gitstream-core already understands + */ +function normalizeForEngine(raw) { + const envelope = tryParsePayload(raw); + if (!envelope) { + // Bare base64(gzip), which the engine inflates on its own. + return raw; + } + if (envelope.type === COMPRESSED_PAYLOAD && envelope.data) { + return envelope.data; + } + if (envelope.type === OVERSIZED_PAYLOAD_REFERENCE) { + return JSON.stringify(envelope); + } + return raw; +} + function toStepOutputs(payload) { const hasCmRepo = payload.hasCmRepo === true; return { @@ -178,7 +208,10 @@ module.exports = async core => { ); core.info(`client_payload mode=${mode}`); - const outputs = toStepOutputs(payload); + const outputs = { + ...toStepOutputs(payload), + client_payload: normalizeForEngine(PAYLOAD_ARG || ''), + }; // The installation token rides inside client_payload, so mask it before it // reaches an output or a later step's env dump. if (outputs.github_token) { @@ -193,3 +226,4 @@ module.exports = async core => { }; module.exports.toStepOutputs = toStepOutputs; +module.exports.normalizeForEngine = normalizeForEngine; From b68377ff6ed7b2d1873e5b596ae881e4861beac7 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 18:46:05 +0300 Subject: [PATCH 5/6] fix: update github-script action version and clean up comments in payload resolution --- action.yml | 4 +--- resolve-payload-fields.js | 11 ----------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/action.yml b/action.yml index 83a15dc3..575a49a5 100644 --- a/action.yml +++ b/action.yml @@ -40,7 +40,7 @@ runs: # payload. See resolve-payload-fields.js for the resolution logic and its outputs. - name: Resolve payload fields id: payload-fields - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PAYLOAD_ARG: ${{ github.event.inputs.client_payload }} RESOLVER_URL_ARG: ${{ github.event.inputs.resolver_url }} @@ -69,8 +69,6 @@ runs: env: BASE_REF_ARG: ${{ github.event.inputs.base_ref }} HEAD_REF_ARG: ${{ github.event.inputs.head_ref }} - # Normalized rather than the raw input: the trigger double-encodes every tier so `run-name` - # can parse it, and the engine reads the same value expecting its own shapes. PAYLOAD_ARG: ${{ steps.payload-fields.outputs.client_payload }} URL_ARG: ${{ steps.payload-fields.outputs.url }} with: diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index 01c6ec82..2c5d7c43 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -157,17 +157,6 @@ async function resolvePayload(raw, resolverUrl, core) { * in step conditions. */ /** - * What the docker rules engine receives as CLIENT_PAYLOAD. - * - * Two consumers read the same dispatch input and need different shapes. The workflow's `run-name` is - * evaluated before any step exists, and the template we publish does - * `fromJSON(fromJSON(client_payload))`, so the trigger double-encodes every tier to keep that - * resolving to an object. gitstream-core resolves the payload itself and expects the shapes it has - * always taken: one JSON.parse for the reference, gzip magic bytes for the compressed form. Stripping - * the outer layer here is what lets both read the same dispatch. - * - * Only the outer layer comes off - the payload a reference points at is never fetched here. - * * @param {string} raw the `client_payload` input, verbatim * @returns {string} the form gitstream-core already understands */ From cdbcb565fc2b670587d50dae158cf92f5e37734d Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 18:50:44 +0300 Subject: [PATCH 6/6] fix: reattach the toStepOutputs doc to its function Inserting normalizeForEngine above it left the JSDoc describing the wrong function, and toStepOutputs with none. Co-Authored-By: Claude Opus 5 (1M context) --- resolve-payload-fields.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index 2c5d7c43..8872f127 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -151,11 +151,6 @@ async function resolvePayload(raw, resolverUrl, core) { return { mode: 'plain', payload: parsePayload(raw) }; } -/** - * Maps a resolved payload to the step outputs v1's action.yml consumes. Output - * values are strings, so booleans are stringified to be compared as `== 'true'` - * in step conditions. - */ /** * @param {string} raw the `client_payload` input, verbatim * @returns {string} the form gitstream-core already understands @@ -175,6 +170,11 @@ function normalizeForEngine(raw) { return raw; } +/** + * Maps a resolved payload to the step outputs v1's action.yml consumes. Output + * values are strings, so booleans are stringified to be compared as `== 'true'` + * in step conditions. + */ function toStepOutputs(payload) { const hasCmRepo = payload.hasCmRepo === true; return {