From fafee578aaffda8dc645f0f3d36b4d5464252d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20Snoksrud?= Date: Fri, 28 Aug 2026 13:05:55 +0200 Subject: [PATCH 1/4] discord-notify: add a composite action that retries the webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reusable workflow is job-level, so it cannot be a step: that is why gaia had to ship the same retry logic as a script and check it out to run it. A composite action can be a step and the runner fetches it, so callers need no checkout, no sparse-checkout and no continue-on-error trap around one. The retry engine is gaia's, unchanged in behaviour: honour retry_after and X-RateLimit-Reset-After, bounded 5 attempts / 30s per wait / 60s total, --max-time so a hung connection cannot stall a job, no retry on a non-429 4xx, and a non-zero exit when the message never lands. Two things are new, both because an action has an interface where a script had argv. `content` takes plain text and does the JSON encoding, so no caller writes jq to get a title with a quote in it into valid JSON; `payload` still takes a whole object for embeds. And fail-on-undeliverable makes gaia's exit-code split declarative instead of a trailing `|| true` — a notifier that is the deliverable may fail, one reporting someone else's failure may not, and the ::error:: annotation fires either way. Inputs cross into the script through env only. A PR title is attacker-controlled text and must never be interpolated into a shell body. Replayed against a stubbed curl and sleep: 45 assertions covering gaia's original 11 cases plus content encoding, payload validation, the undeliverable switch and the delivered output. shellcheck 0.11.0 clean. No request reached Discord; the harness webhook is example.invalid. --- README.md | 41 +++++++++ discord-notify/action.yml | 54 +++++++++++ discord-notify/discord-notify.sh | 151 +++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 discord-notify/action.yml create mode 100755 discord-notify/discord-notify.sh diff --git a/README.md b/README.md index c8ecff3..c10bdce 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ # github-actions A collection of reusable GitHub Actions for Novem + +## `discord-notify` + +Post one message to a Discord webhook, honouring the rate limit. A plain +`curl` exits 0 whatever Discord answers, so a 429 — what several workflows +firing at once earns you — printed the rate-limit body into the log and left +the step green with the message thrown away. + +```yaml +- uses: novem-code/github-actions/discord-notify@ + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + content: "🚀 deploy finished" +``` + +Pin the **full commit SHA**, never a tag or branch: refs here are mutable. + +| Input | Required | Default | | +|---|---|---|---| +| `webhook-url` | yes | | The webhook. A composite action cannot declare `secrets:`, so pass the secret as this input; masking is by value, so it stays redacted. | +| `content` | one of | `''` | Message text. JSON-encoded for you — quotes and newlines are safe without reaching for `jq`. | +| `payload` | one of | `''` | A complete Discord JSON object, for embeds. Give exactly one of `content` / `payload`. | +| `fail-on-undeliverable` | no | `'true'` | Whether an undeliverable message fails the step. | + +Output `delivered` is `"true"`/`"false"`. + +### Which way to set `fail-on-undeliverable` + +A notifier that **is** the deliverable may fail the run; a notifier reporting +**someone else's** failure may not. Leave it `true` when sending the message is +the whole job — nothing else records that it vanished. Set it `false` in an +`if: failure()` step, where a red notifier is read as the failure it was +reporting. Either way an `::error::` annotation lands on the run summary, so +the loss is never silent. + +Retries honour `retry_after` and `X-RateLimit-Reset-After`, bounded at 5 +attempts / 30s per wait / 60s total, with `--max-time` so one hung connection +cannot stall a job. A 4xx that is not a 429 is not retried — a malformed +payload or a dead webhook does not improve by repetition. + +Needs `bash`, `curl` and `jq` on the runner. diff --git a/discord-notify/action.yml b/discord-notify/action.yml new file mode 100644 index 0000000..9a2bb80 --- /dev/null +++ b/discord-notify/action.yml @@ -0,0 +1,54 @@ +name: 'Discord notify' +description: >- + POST one message to a Discord webhook, honouring the rate limit and failing + loudly when the message never lands. + +inputs: + webhook-url: + description: >- + The Discord webhook URL. A composite action cannot declare `secrets:`, + so pass the secret straight in: `webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}`. + Masking is by value, so it stays redacted in logs. + required: true + content: + description: >- + Message text. The action JSON-encodes it, so quotes, backslashes and + newlines are safe without the caller reaching for jq. Give exactly one + of `content` or `payload`. + required: false + default: '' + payload: + description: >- + A complete Discord webhook JSON object, for embeds and anything + `content` cannot express. Give exactly one of `content` or `payload`. + required: false + default: '' + fail-on-undeliverable: + description: >- + Whether a message that never lands fails the step. Leave `true` when + sending the message IS the job — nothing else records that it vanished. + Set `false` in an `if: failure()` notifier, where a red step would be + read as the failure it was reporting. The `::error::` annotation is + emitted either way, so the loss is on the run summary regardless. + required: false + default: 'true' + +outputs: + delivered: + description: '"true" when Discord accepted the message, "false" when it never landed.' + value: ${{ steps.send.outputs.delivered }} + +runs: + using: composite + steps: + # Everything crosses into the script through env, never through `${{ }}` + # interpolated into the shell body — a PR title is attacker-controlled text + # and must not be able to become code. + - id: send + shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ inputs.webhook-url }} + DISCORD_CONTENT: ${{ inputs.content }} + DISCORD_PAYLOAD: ${{ inputs.payload }} + DISCORD_FAIL_ON_UNDELIVERABLE: ${{ inputs.fail-on-undeliverable }} + run: '"$GITHUB_ACTION_PATH/discord-notify.sh"' diff --git a/discord-notify/discord-notify.sh b/discord-notify/discord-notify.sh new file mode 100755 index 0000000..eac17de --- /dev/null +++ b/discord-notify/discord-notify.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Post one message to a Discord webhook, honouring the rate limit. +# +# A plain `curl` exits 0 whatever Discord answers, so a 429 — which is what +# several workflows firing at once earns you — threw the notification away +# without a trace. Discord's 429 names the wait it wants: +# +# {"message": "Service resource is being rate limited.", "retry_after": 3, ...} +# +# Wait that long and try again, bounded so no job can hang on a notification, +# and exit non-zero when the message never lands — unless the caller set +# fail-on-undeliverable: false, which is for a notifier reporting someone +# else's failure, whose own red would be mistaken for that failure. +# +# Everything arrives via env, set by action.yml from the action's inputs. +# Nothing is interpolated into this file, so caller-controlled text (a PR +# title, a commit message) can never become shell. + +set -euo pipefail + +: "${DISCORD_WEBHOOK_URL:?webhook-url is required and was empty}" + +content=${DISCORD_CONTENT:-} +payload=${DISCORD_PAYLOAD:-} +fail_on_undeliverable=${DISCORD_FAIL_ON_UNDELIVERABLE:-true} + +if [ -n "$content" ] && [ -n "$payload" ]; then + echo "::error::discord-notify: give content or payload, not both" + exit 1 +fi + +# Encode here rather than making every caller reach for jq: getting a title +# with a quote in it into valid JSON by hand is the bug this avoids. +if [ -z "$payload" ]; then + if [ -z "$content" ]; then + echo "::error::discord-notify: one of content or payload is required" + exit 1 + fi + payload=$(jq -n --arg content "$content" '{content: $content}') +elif ! jq -e . >/dev/null 2>&1 <<<"$payload"; then + echo "::error::discord-notify: payload is not valid JSON" + exit 1 +fi + +MAX_ATTEMPTS=5 +MAX_TOTAL_WAIT=60 # across all retries — the ceiling on delaying a job +MAX_SINGLE_WAIT=30 +DEFAULT_WAIT=2 # a 429 that names no wait at all + +body=$(mktemp) +hdrs=$(mktemp) +trap 'rm -f "$body" "$hdrs"' EXIT + +# Report the verdict once, in one place, so the output and the exit code +# cannot disagree. `delivered` lets a caller that suppressed the failure still +# branch on what happened. +finish() { + local delivered=$1 + [ -z "${GITHUB_OUTPUT:-}" ] || echo "delivered=$delivered" >>"$GITHUB_OUTPUT" + [ "$delivered" = false ] || exit 0 + [ "$fail_on_undeliverable" != false ] || exit 0 + exit 1 +} + +# Whole seconds, rounded UP. retry_after is fractional and the shell has no +# float arithmetic; oversleeping costs nothing, undersleeping earns another 429. +ceil_seconds() { + local value=${1:-} whole=${1%%.*} + case $whole in '' | *[!0-9]*) echo 0 ;; *) + if [ "$whole" = "$value" ]; then echo "$whole"; else echo $((whole + 1)); fi + ;; + esac +} + +# A response header by name (give it lowercase), empty when absent. Bash +# builtins only: the self-hosted runners' nix shells promise curl and jq on +# PATH, not awk. +header() { + local want=$1 line key value="" + while IFS= read -r line; do + line=${line%$'\r'} + key=${line%%:*} + if [ "${key,,}" = "$want" ]; then value=${line#*: }; fi + done <"$hdrs" + printf '%s' "$value" +} + +# What the response actually asked for, in descending authority. The body is +# Discord's own answer; the headers cover a 429 raised in front of the API, +# which arrives with no JSON body at all. +rate_limit_wait() { + local secs + secs=$(ceil_seconds "$(jq -r '.retry_after // empty' "$body" 2>/dev/null || true)") + [ "$secs" -gt 0 ] || secs=$(ceil_seconds "$(header x-ratelimit-reset-after)") + [ "$secs" -gt 0 ] || secs=$(ceil_seconds "$(header retry-after)") + [ "$secs" -gt 0 ] || secs=$DEFAULT_WAIT + echo "$secs" +} + +waited=0 +attempt=1 +while :; do + # --max-time bounds a hung connection: without it "bounded retry" still + # leaves one attempt able to stall the job indefinitely. + status=$(curl -sS --connect-timeout 5 --max-time 15 \ + -o "$body" -D "$hdrs" -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -X POST --data-binary "$payload" \ + "$DISCORD_WEBHOOK_URL") || status=000 + + case $status in + 2*) + finish true + ;; + 429) + delay=$(rate_limit_wait) + scope=$(jq -r 'if .global == true then "global" else "route" end' "$body" 2>/dev/null || echo route) + reason="rate limited, $scope" + ;; + 5* | 000) + # Discord is down, or the request never landed. Neither says how long to + # wait, so back off on the attempt count. + delay=$((attempt * 2)) + reason="transient" + ;; + *) + # 400/401/404: a malformed payload or a dead webhook does not improve by + # being repeated. Fail now and name it, rather than burning the budget. + echo "::error::discord notification refused with HTTP $status — payload or webhook, not a rate limit" + detail=$(<"$body") + printf 'discord: HTTP %s: %s\n' "$status" "${detail:0:500}" >&2 + finish false + ;; + esac + + [ "$delay" -le "$MAX_SINGLE_WAIT" ] || delay=$MAX_SINGLE_WAIT + + if [ "$attempt" -ge "$MAX_ATTEMPTS" ] || [ $((waited + delay)) -gt "$MAX_TOTAL_WAIT" ]; then + break + fi + + printf 'discord: HTTP %s (%s), retrying in %ss (attempt %s/%s)\n' \ + "$status" "$reason" "$delay" "$attempt" "$MAX_ATTEMPTS" >&2 + sleep "$delay" + waited=$((waited + delay)) + attempt=$((attempt + 1)) +done + +echo "::error::discord notification never landed: $attempt attempts over ${waited}s, last status $status" +finish false From 447ee413c838a21add6cc45fc7431b947edcecd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20Snoksrud?= Date: Fri, 28 Aug 2026 13:07:46 +0200 Subject: [PATCH 2/4] discord: route both notifiers through the retrying action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both workflows ended in a bare `curl`, so a rate-limited notification printed Discord's refusal into the log and the step went green having sent nothing. This is the same defect as gaia#2409, in the repo gaia calls for it. The `workflow_call` interface does not move — same inputs, same `discord_webhook` secret — so pinned callers are unaffected. What changes is that an undeliverable message now reddens the run. Sending it is these workflows' whole purpose; nothing else would record that it vanished. The action is referenced by full path and SHA rather than a local `./discord-notify`. A relative action path inside a reusable workflow resolves against the CALLER's workspace, which never has this repo checked out, so the self-reference has to go through the registry. That is also why the pin is a SHA: a caller who pins this workflow would otherwise get a floating action underneath it. Message building keeps its own step and hands the text over through GITHUB_OUTPUT with a random delimiter, so a title cannot close the heredoc and inject further outputs. `github.event.action` and `.pull_request.merged` move into env with the rest — they were interpolated straight into the shell body. Since `content` now does the JSON encoding, both call sites lose their jq invocation. --- .github/workflows/discord_pr.yml | 55 ++++++++++++++++++------------ .github/workflows/discord_push.yml | 38 ++++++++++++--------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/.github/workflows/discord_pr.yml b/.github/workflows/discord_pr.yml index c166c83..1eaf3be 100644 --- a/.github/workflows/discord_pr.yml +++ b/.github/workflows/discord_pr.yml @@ -35,17 +35,21 @@ jobs: notify_pr: runs-on: ubuntu-latest steps: - - name: Send PR notification to Discord + - name: Compose the message + id: compose env: PR_TITLE: ${{ inputs.pr_title }} PR_NUMBER: ${{ inputs.pr_number }} PR_URL: ${{ inputs.pr_url }} REPO_FULL_NAME: ${{ inputs.repo_full_name }} PR_DRAFT: ${{ inputs.pr_draft }} - DISCORD_WEBHOOK: ${{ secrets.discord_webhook }} + # Through env, not interpolated into the body: a PR title is + # attacker-controlled text and must not be able to become shell. + EVENT_ACTION: ${{ github.event.action }} + PR_MERGED: ${{ github.event.pull_request.merged }} run: | # Determine the message based on the event type and draft status - if [[ "${{ github.event.action }}" == "opened" ]]; then + if [[ "$EVENT_ACTION" == "opened" ]]; then if [[ "$PR_DRAFT" == "true" ]]; then ACTION_ICON="📝" ACTION_TEXT="draft has been opened." @@ -53,35 +57,44 @@ jobs: ACTION_ICON="<:devpropened:1275356590316322858>" ACTION_TEXT="has been opened. Please review!" fi - elif [[ "${{ github.event.action }}" == "ready_for_review" ]]; then + elif [[ "$EVENT_ACTION" == "ready_for_review" ]]; then ACTION_ICON="<:devpropened:1275356590316322858>" ACTION_TEXT="is ready for review!" - elif [[ "${{ github.event.action }}" == "converted_to_draft" ]]; then + elif [[ "$EVENT_ACTION" == "converted_to_draft" ]]; then ACTION_ICON="📝" ACTION_TEXT="has been converted to draft." - elif [[ "${{ github.event.pull_request.merged }}" == "true" ]]; then + elif [[ "$PR_MERGED" == "true" ]]; then ACTION_ICON="<:devprmerged:1275356589284393021>" ACTION_TEXT="has been merged!" - elif [[ "${{ github.event.action }}" == "closed" ]]; then + elif [[ "$EVENT_ACTION" == "closed" ]]; then ACTION_ICON="<:devprclosed:1275356586218356776>" ACTION_TEXT="has been closed!" else echo "Unhandled event type. Exiting." + echo "send=false" >> "$GITHUB_OUTPUT" exit 0 fi - REPO_NAME=$(echo $REPO_FULL_NAME | cut -d'/' -f2) - MESSAGE=$(jq -n \ - --arg repo "$REPO_NAME" \ - --arg number "$PR_NUMBER" \ - --arg title "$PR_TITLE" \ - --arg url "$PR_URL" \ - --arg icon "$ACTION_ICON" \ - --arg action "$ACTION_TEXT" \ - '{content: "\($icon) \($repo) > [PR #\($number): \($title)](<\($url)>) \($action)"}') + REPO_NAME=${REPO_FULL_NAME#*/} + CONTENT="$ACTION_ICON $REPO_NAME > [PR #$PR_NUMBER: $PR_TITLE](<$PR_URL>) $ACTION_TEXT" + + # Random delimiter: a title cannot be allowed to close the heredoc + # and inject further outputs. + delim="EOF_${RANDOM}${RANDOM}" + { + printf 'content<<%s\n' "$delim" + printf '%s\n' "$CONTENT" + printf '%s\n' "$delim" + printf 'send=true\n' + } >> "$GITHUB_OUTPUT" - # Send the notification to Discord - curl -H "Content-Type: application/json" \ - -X POST \ - -d "$MESSAGE" \ - "$DISCORD_WEBHOOK" + # The action retries a rate-limited webhook and exits non-zero if the + # message never lands. Sending it is this workflow's whole purpose, so + # that failure is allowed to redden the run — nothing else would record + # that the notification vanished. + - name: Send PR notification to Discord + if: steps.compose.outputs.send == 'true' + uses: novem-code/github-actions/discord-notify@fafee578aaffda8dc645f0f3d36b4d5464252d87 + with: + webhook-url: ${{ secrets.discord_webhook }} + content: ${{ steps.compose.outputs.content }} diff --git a/.github/workflows/discord_push.yml b/.github/workflows/discord_push.yml index dea3135..4319302 100644 --- a/.github/workflows/discord_push.yml +++ b/.github/workflows/discord_push.yml @@ -45,27 +45,33 @@ jobs: echo "is_direct_commit=false" >> $GITHUB_OUTPUT fi - - name: Send push notification to Discord + - name: Compose the message + id: compose if: steps.input_check.outputs.is_direct_commit == 'true' env: BRANCH_NAME: ${{ steps.input_check.outputs.branch_name }} COMMIT_MESSAGE: ${{ inputs.commit_message }} COMMIT_URL: ${{ inputs.commit_url }} REPO_FULL_NAME: ${{ inputs.repo_full_name }} - DISCORD_WEBHOOK: ${{ secrets.discord_webhook }} run: | - REPO_NAME=$(echo $REPO_FULL_NAME | cut -d'/' -f2) - COMMIT_TITLE="$(echo "$COMMIT_MESSAGE" | head -1)" - MESSAGE=$(jq -n \ - --arg repo "$REPO_NAME" \ - --arg branch_name "$BRANCH_NAME" \ - --arg commit_title "$COMMIT_TITLE" \ - --arg commit_url "$COMMIT_URL" \ - --arg icon "<:devcommit:1275358039884435517>" \ - '{content: "\($icon) \($repo) > Direct push to \($branch_name): [\($commit_title)](<\($commit_url)>)"}') + REPO_NAME=${REPO_FULL_NAME#*/} + COMMIT_TITLE="$(printf '%s\n' "$COMMIT_MESSAGE" | head -1)" + CONTENT="<:devcommit:1275358039884435517> $REPO_NAME > Direct push to $BRANCH_NAME: [$COMMIT_TITLE](<$COMMIT_URL>)" + + # Random delimiter: a commit title cannot be allowed to close the + # heredoc and inject further outputs. + delim="EOF_${RANDOM}${RANDOM}" + { + printf 'content<<%s\n' "$delim" + printf '%s\n' "$CONTENT" + printf '%s\n' "$delim" + } >> "$GITHUB_OUTPUT" - # Send the notification to Discord - curl -H "Content-Type: application/json" \ - -X POST \ - -d "$MESSAGE" \ - "$DISCORD_WEBHOOK" + # Sending the message is this workflow's whole purpose, so an + # undeliverable one is allowed to redden the run. + - name: Send push notification to Discord + if: steps.input_check.outputs.is_direct_commit == 'true' + uses: novem-code/github-actions/discord-notify@fafee578aaffda8dc645f0f3d36b4d5464252d87 + with: + webhook-url: ${{ secrets.discord_webhook }} + content: ${{ steps.compose.outputs.content }} From d9a94f342c5cdacb85f91a2f30d0000004f6ca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20Snoksrud?= Date: Fri, 28 Aug 2026 13:58:54 +0200 Subject: [PATCH 3/4] discord-notify: keep the secrets example out of the input description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner evaluates input descriptions, so the sentence explaining that a composite action cannot use the secrets context named it in expression syntax and failed the manifest before the action ran: action.yml (Line: 8, Col: 18): Unrecognized named-value: 'secrets' The advice was right; writing it as a live expression was not. It now names secrets.DISCORD_WEBHOOK_URL as plain text and says why the wrapper is missing, so the next person does not restore it. No local check could have caught this. The harness exercises the script through a stubbed curl, and the manifest was only checked for YAML validity — it parses fine and is still rejected by the expression evaluator. CI was the first thing to actually evaluate it. --- discord-notify/action.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/discord-notify/action.yml b/discord-notify/action.yml index 9a2bb80..b85c3f6 100644 --- a/discord-notify/action.yml +++ b/discord-notify/action.yml @@ -6,9 +6,13 @@ description: >- inputs: webhook-url: description: >- - The Discord webhook URL. A composite action cannot declare `secrets:`, - so pass the secret straight in: `webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}`. - Masking is by value, so it stays redacted in logs. + The Discord webhook URL. A composite action cannot declare `secrets:`, so + the caller passes the secret in as this input, reading it from + secrets.DISCORD_WEBHOOK_URL at its own call site. Masking is by value, so + it stays redacted crossing the boundary. This text names that context + without the expression wrapper on purpose: the runner evaluates input + descriptions too, so writing it out here fails the manifest before the + action ever runs. required: true content: description: >- From 925685e29c497bdec2c75643e23c9847e8ec6c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20Snoksrud?= Date: Fri, 28 Aug 2026 13:59:03 +0200 Subject: [PATCH 4/4] discord: repoint the pin at the fixed action manifest Both notifiers pinned fafee578, the commit whose action.yml the runner rejects, so fixing the manifest alone left them fetching the broken one. They now pin the fix. A pin inside this repo has to be repointed by a follow-up commit rather than an amend: the SHA has to exist before anything can reference it, and the earlier commits are already pushed. --- .github/workflows/discord_pr.yml | 2 +- .github/workflows/discord_push.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/discord_pr.yml b/.github/workflows/discord_pr.yml index 1eaf3be..90d2823 100644 --- a/.github/workflows/discord_pr.yml +++ b/.github/workflows/discord_pr.yml @@ -94,7 +94,7 @@ jobs: # that the notification vanished. - name: Send PR notification to Discord if: steps.compose.outputs.send == 'true' - uses: novem-code/github-actions/discord-notify@fafee578aaffda8dc645f0f3d36b4d5464252d87 + uses: novem-code/github-actions/discord-notify@d9a94f342c5cdacb85f91a2f30d0000004f6ca73 with: webhook-url: ${{ secrets.discord_webhook }} content: ${{ steps.compose.outputs.content }} diff --git a/.github/workflows/discord_push.yml b/.github/workflows/discord_push.yml index 4319302..0c00d10 100644 --- a/.github/workflows/discord_push.yml +++ b/.github/workflows/discord_push.yml @@ -71,7 +71,7 @@ jobs: # undeliverable one is allowed to redden the run. - name: Send push notification to Discord if: steps.input_check.outputs.is_direct_commit == 'true' - uses: novem-code/github-actions/discord-notify@fafee578aaffda8dc645f0f3d36b4d5464252d87 + uses: novem-code/github-actions/discord-notify@d9a94f342c5cdacb85f91a2f30d0000004f6ca73 with: webhook-url: ${{ secrets.discord_webhook }} content: ${{ steps.compose.outputs.content }}