From 9e22d2f75c414f71acdaa2893993c1ce1ed8babe Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Tue, 18 Aug 2026 12:22:01 -0700 Subject: [PATCH 1/3] feat: add `summary` input to publish deployed endpoints When `summary: true`, after the command runs the action calls `defang services --json` and: - writes a services table (public endpoints as links, internal services as code) to the GitHub job summary, and - exposes the primary public https endpoint as the `endpoint` output, for wiring into a job's `environment.url`. Writes straight to $GITHUB_STEP_SUMMARY rather than the size-limited step-output channel, so it is safe for large deployments where `capture-output` is disabled. Defaults to false (opt-in). Co-Authored-By: Claude Opus 4.8 --- README.md | 36 ++++++++++++++++++++++++++++++++++++ action.yaml | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/README.md b/README.md index 318d3cd..be30e43 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,37 @@ jobs: capture-output: false ``` +### Publishing the Deployment Summary + +Set `summary: true` to surface the deployment result on the GitHub run instead of leaving it buried in the action log. After the command runs, the action calls `defang services --json` and: + +- writes a table of deployed services (public endpoints as links, internal services as code) to the [job summary](https://github.blog/news-insights/product-news/supercharging-github-actions-with-job-summaries/), and +- exposes the primary public `https://` endpoint as the `endpoint` output. + +Wire that output into the job's `environment.url` to get a clickable **"View deployment"** link on the run, the Environments tab, and the repo sidebar: + +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: production + url: ${{ steps.deploy.outputs.endpoint }} + permissions: + contents: read + id-token: write + steps: + # [...] + - name: Deploy + id: deploy + uses: DefangLabs/defang-github-action@v2 + with: + command: "compose up" + summary: true +``` + +Unlike `capture-output`, this writes straight to the job summary (1 MiB per step) rather than through the size-limited step-output channel, and `defang services` output is small and bounded — so it is safe even for large deployments where `capture-output` is disabled. + ### Full Example Here is a full example of a GitHub workflow that does everything we've discussed so far: @@ -142,6 +173,9 @@ on: jobs: test: runs-on: ubuntu-latest + environment: + name: production + url: ${{ steps.deploy.outputs.endpoint }} permissions: contents: read id-token: write @@ -151,6 +185,7 @@ jobs: uses: actions/checkout@v4 - name: Deploy + id: deploy uses: DefangLabs/defang-github-action@v2 with: cli-version: v3.5.2 @@ -161,6 +196,7 @@ jobs: provider: "aws" # deprecated in favor of stack: stack: "production" command: "compose up" + summary: true verbose: true env: API_KEY: ${{ secrets.API_KEY }} diff --git a/action.yaml b/action.yaml index 3473a37..8906489 100644 --- a/action.yaml +++ b/action.yaml @@ -62,11 +62,18 @@ inputs: description: "Capture the command's stdout as an output. Disable for large deployments to avoid GitHub Actions memory limits." required: false default: "true" + summary: + description: "After the command runs, write a table of deployed services to the GitHub job summary and expose the primary public endpoint as the 'endpoint' output. Uses 'defang services --json'." + required: false + default: "false" outputs: stdout: description: "The stdout of the command. Only available when capture-output is 'true'." value: ${{ steps.forward-output.outputs.stdout }} + endpoint: + description: "The primary public https endpoint of the deployed project (best-effort). Only set when 'summary' is 'true'." + value: ${{ steps.summary.outputs.endpoint }} runs: using: "composite" @@ -231,3 +238,39 @@ runs: env: STDOUT: ${{ steps.command.outputs.stdout }} + - name: Deployment summary + id: summary + if: ${{ inputs['summary'] == 'true' }} + shell: bash + working-directory: ${{ inputs.cwd }} + run: | + params=() + for filename in $COMPOSE_FILES; do + params+=("-f") + params+=("$filename") + done + + # Fetch the deployed services as JSON. Stack/provider/project come from + # the environment set earlier, so they don't need to be passed again. + services=$(defang services "${params[@]}" --json 2>/dev/null || echo '[]') + + # Expose the primary public endpoint (first https:// service) as an output. + endpoint=$(echo "$services" | jq -r 'map(select((.Endpoint // "") | startswith("https://"))) | .[0].Endpoint // empty') + echo "endpoint=$endpoint" >> "$GITHUB_OUTPUT" + + # Render a services table to the job summary. Public endpoints become + # links; internal services are shown as code. + { + echo "## 🚀 Deployed services" + echo "" + if [ "$(echo "$services" | jq 'length')" -gt 0 ]; then + echo "| Service | Status | Endpoint |" + echo "|---|---|---|" + echo "$services" | jq -r '.[] | "| \(.Service) | \(.Status) | " + (if ((.Endpoint // "") | startswith("https://")) then "[\(.Endpoint)](\(.Endpoint))" else "`\(.Endpoint)`" end) + " |"' + else + echo "_No services found._" + fi + } >> "$GITHUB_STEP_SUMMARY" + env: + COMPOSE_FILES: ${{ inputs['compose-files'] }} + From 6a9a025db2a9c4c17d5c4c0c148be25e414529b3 Mon Sep 17 00:00:00 2001 From: defangdevs Date: Tue, 18 Aug 2026 14:49:52 -0700 Subject: [PATCH 2/3] Address PR review feedback on summary input - Default `summary` to enabled when `command` is the default `compose up`, keep it opt-in for other commands (empty default distinguishes "not set" from an explicit "false"). - Export COMPOSE_FILE (with COMPOSE_PATH_SEPARATOR pinned to ':') instead of building a -f/-f/-f loop, in both the Defang command step and the Deployment summary step. Docker Compose and the Defang CLI read this natively. - Add set -o pipefail to the Deployment summary step, which uses pipelines. - Fix the services fallback so a failing `defang services --json` call cannot leave two concatenated JSON values in $services. - Report "No services found." when there are services but no public endpoint, matching the PR's stated contract. --- action.yaml | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/action.yaml b/action.yaml index 8906489..67577f3 100644 --- a/action.yaml +++ b/action.yaml @@ -63,9 +63,9 @@ inputs: required: false default: "true" summary: - description: "After the command runs, write a table of deployed services to the GitHub job summary and expose the primary public endpoint as the 'endpoint' output. Uses 'defang services --json'." + description: "After the command runs, write a table of deployed services to the GitHub job summary and expose the primary public endpoint as the 'endpoint' output. Uses 'defang services --json'. Defaults to enabled when 'command' is the default 'compose up'. Set to 'false' to disable, or 'true' to force-enable for other commands." required: false - default: "false" + default: "" outputs: stdout: @@ -190,11 +190,17 @@ runs: working-directory: ${{ inputs.cwd }} run: | set -o pipefail + + # Docker Compose (and the Defang CLI) natively read the compose file + # list from COMPOSE_FILE, so export that instead of building a + # repeated -f/-f/-f list ourselves. COMPOSE_PATH_SEPARATOR pins the + # separator to ':' regardless of the runner OS. + if [[ -n "$COMPOSE_FILES" ]]; then + export COMPOSE_PATH_SEPARATOR=":" + export COMPOSE_FILE="${COMPOSE_FILES// /:}" + fi + params=() - for filename in $COMPOSE_FILES; do - params+=("-f") - params+=("$filename") - done if [[ -n "${{ inputs['mode'] }}" ]]; then params+=("--mode=${{ inputs['mode'] }}") fi @@ -240,30 +246,41 @@ runs: - name: Deployment summary id: summary - if: ${{ inputs['summary'] == 'true' }} + if: ${{ inputs['summary'] == 'true' || (inputs['summary'] == '' && inputs['command'] == 'compose up') }} shell: bash working-directory: ${{ inputs.cwd }} run: | - params=() - for filename in $COMPOSE_FILES; do - params+=("-f") - params+=("$filename") - done + set -o pipefail + + # Docker Compose (and the Defang CLI) natively read the compose file + # list from COMPOSE_FILE; see the "Defang $command" step above. + if [[ -n "$COMPOSE_FILES" ]]; then + export COMPOSE_PATH_SEPARATOR=":" + export COMPOSE_FILE="${COMPOSE_FILES// /:}" + fi # Fetch the deployed services as JSON. Stack/provider/project come from # the environment set earlier, so they don't need to be passed again. - services=$(defang services "${params[@]}" --json 2>/dev/null || echo '[]') + # Only fall back to an empty array on failure, so a successful run + # never yields two concatenated JSON values. + if services=$(defang services --json 2>/dev/null); then + : + else + services='[]' + fi # Expose the primary public endpoint (first https:// service) as an output. endpoint=$(echo "$services" | jq -r 'map(select((.Endpoint // "") | startswith("https://"))) | .[0].Endpoint // empty') echo "endpoint=$endpoint" >> "$GITHUB_OUTPUT" # Render a services table to the job summary. Public endpoints become - # links; internal services are shown as code. + # links; internal services are shown as code. Deployments with no + # services, or with services but no public endpoint, report + # "No services found." per the summary's contract. { echo "## 🚀 Deployed services" echo "" - if [ "$(echo "$services" | jq 'length')" -gt 0 ]; then + if [ -n "$endpoint" ] && [ "$(echo "$services" | jq 'length')" -gt 0 ]; then echo "| Service | Status | Endpoint |" echo "|---|---|---|" echo "$services" | jq -r '.[] | "| \(.Service) | \(.Status) | " + (if ((.Endpoint // "") | startswith("https://")) then "[\(.Endpoint)](\(.Endpoint))" else "`\(.Endpoint)`" end) + " |"' From dd7410dd38abb8308fc0c39b9cd5630d10c51994 Mon Sep 17 00:00:00 2001 From: defangdevs Date: Tue, 18 Aug 2026 14:54:43 -0700 Subject: [PATCH 3/3] fix: flip capture-output default, consolidate COMPOSE_FILE export - capture-output now defaults to 'false' (per review): the only in-org consumer of the stdout output is defang-mvp's run-estimate action, which gets an explicit capture-output: true in a companion PR; defang-global has no workflows using this action - export COMPOSE_FILE once via GITHUB_ENV (like DEFANG_STACK etc.) instead of per-step, remove the last -f loop (Defang Config Set), and make the conversion newline-safe: the compose-files input is documented as space-separated but our own test workflow passes a newline-separated list, which a plain space substitution would mangle - auto-summary also for 'compose up' with extra args (e.g. the README's 'compose up --project-name my-project' example), not only the bare default command - skip the summary gracefully when jq is missing (self-hosted runners) - README: document the new defaults Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017evPVG7SGJNZHGr4LnzDQ6 --- README.md | 12 +++++----- action.yaml | 65 +++++++++++++++++++++++------------------------------ 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index be30e43..e89b935 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,9 @@ jobs: command: "compose up --project-name my-project" ``` -### Disabling Output Capture +### Capturing Output -By default, the action captures the command's stdout as an output (`outputs.stdout`). For large deployments, this can exceed GitHub Actions' memory limits and cause the workflow to fail even though the deployment succeeded. If you don't need the stdout output, disable it: +Set `capture-output: true` to capture the command's stdout as an output (`outputs.stdout`). This is off by default because large deployments can exceed GitHub Actions' memory limits and cause the workflow to fail even though the deployment succeeded: ```yaml jobs: @@ -124,12 +124,12 @@ jobs: - name: Deploy uses: DefangLabs/defang-github-action@v2 with: - capture-output: false + capture-output: true ``` ### Publishing the Deployment Summary -Set `summary: true` to surface the deployment result on the GitHub run instead of leaving it buried in the action log. After the command runs, the action calls `defang services --json` and: +When the command is `compose up` (the default), the action surfaces the deployment result on the GitHub run instead of leaving it buried in the action log. After the command runs, the action calls `defang services --json` and: - writes a table of deployed services (public endpoints as links, internal services as code) to the [job summary](https://github.blog/news-insights/product-news/supercharging-github-actions-with-job-summaries/), and - exposes the primary public `https://` endpoint as the `endpoint` output. @@ -153,11 +153,12 @@ jobs: uses: DefangLabs/defang-github-action@v2 with: command: "compose up" - summary: true ``` Unlike `capture-output`, this writes straight to the job summary (1 MiB per step) rather than through the size-limited step-output channel, and `defang services` output is small and bounded — so it is safe even for large deployments where `capture-output` is disabled. +Set `summary: "false"` to skip the summary, or `summary: "true"` to force it for commands other than `compose up`. + ### Full Example Here is a full example of a GitHub workflow that does everything we've discussed so far: @@ -196,7 +197,6 @@ jobs: provider: "aws" # deprecated in favor of stack: stack: "production" command: "compose up" - summary: true verbose: true env: API_KEY: ${{ secrets.API_KEY }} diff --git a/action.yaml b/action.yaml index 67577f3..0232c5b 100644 --- a/action.yaml +++ b/action.yaml @@ -59,9 +59,9 @@ inputs: required: false default: "false" capture-output: - description: "Capture the command's stdout as an output. Disable for large deployments to avoid GitHub Actions memory limits." + description: "Capture the command's stdout as the 'stdout' output. Off by default: large deployments can exceed GitHub Actions memory limits." required: false - default: "true" + default: "false" summary: description: "After the command runs, write a table of deployed services to the GitHub job summary and expose the primary public endpoint as the 'endpoint' output. Uses 'defang services --json'. Defaults to enabled when 'command' is the default 'compose up'. Set to 'false' to disable, or 'true' to force-enable for other commands." required: false @@ -72,7 +72,7 @@ outputs: description: "The stdout of the command. Only available when capture-output is 'true'." value: ${{ steps.forward-output.outputs.stdout }} endpoint: - description: "The primary public https endpoint of the deployed project (best-effort). Only set when 'summary' is 'true'." + description: "The primary public https endpoint of the deployed project (best-effort). Only set when the deployment summary runs (see 'summary')." value: ${{ steps.summary.outputs.endpoint }} runs: @@ -118,6 +118,18 @@ runs: [ -n "${{ inputs['provider'] }}" ] && echo "DEFANG_PROVIDER=${{ inputs['provider'] }}" >> $GITHUB_ENV || true [ -n "${{ inputs['stack'] }}" ] && echo "DEFANG_STACK=${{ inputs['stack'] }}" >> $GITHUB_ENV || true [ -n "${{ inputs['project'] }}" ] && echo "COMPOSE_PROJECT_NAME=${{ inputs['project'] }}" >> $GITHUB_ENV || true + # Docker Compose (and the Defang CLI) natively read the compose file + # list from COMPOSE_FILE, so export that once instead of building a + # repeated -f/-f/-f list in every step. The unquoted echo collapses + # any whitespace (spaces or newlines) between filenames before tr + # joins them with ':'; COMPOSE_PATH_SEPARATOR pins the separator to + # ':' regardless of the runner OS. + if [ -n "$COMPOSE_FILES" ]; then + echo "COMPOSE_FILE=$(echo $COMPOSE_FILES | tr ' ' ':')" >> $GITHUB_ENV + echo "COMPOSE_PATH_SEPARATOR=:" >> $GITHUB_ENV + fi + env: + COMPOSE_FILES: ${{ inputs['compose-files'] }} - name: Login to Defang shell: bash @@ -130,13 +142,6 @@ runs: shell: bash if: ${{ inputs['config-env-vars'] != '' || inputs['config-vars-init-random'] != '' || inputs['config-env-vars-init'] != '' }} run: | - # Build compose file parameters - params=() - for filename in $COMPOSE_FILES; do - params+=("-f") - params+=("$filename") - done - # Check for empty environment variables (only for config vars from env) empty=() for source in $CONFIG_ENV_VARS $CONFIG_ENV_VARS_INIT; do @@ -159,26 +164,25 @@ runs: # Set random config variables if [ -n "$CONFIG_VARS_INIT_RANDOM" ]; then echo "Initialize randomly generated configs..." - echo defang config "${params[@]}" set --if-not-set --random $CONFIG_VARS_INIT_RANDOM - defang config "${params[@]}" set --if-not-set --random $CONFIG_VARS_INIT_RANDOM + echo defang config set --if-not-set --random $CONFIG_VARS_INIT_RANDOM + defang config set --if-not-set --random $CONFIG_VARS_INIT_RANDOM fi # Set init config variables if [ -n "$CONFIG_ENV_VARS_INIT" ]; then echo "Initialize configs..." - echo defang config "${params[@]}" set --if-not-set -e $CONFIG_ENV_VARS_INIT - defang config "${params[@]}" set --if-not-set -e $CONFIG_ENV_VARS_INIT + echo defang config set --if-not-set -e $CONFIG_ENV_VARS_INIT + defang config set --if-not-set -e $CONFIG_ENV_VARS_INIT fi # Set regular config variables if [ -n "$CONFIG_ENV_VARS" ]; then echo "Updating configs..." - echo defang config "${params[@]}" set -e $CONFIG_ENV_VARS - defang config "${params[@]}" set -e $CONFIG_ENV_VARS + echo defang config set -e $CONFIG_ENV_VARS + defang config set -e $CONFIG_ENV_VARS fi working-directory: ${{ inputs.cwd }} env: - COMPOSE_FILES: ${{ inputs['compose-files'] }} CONFIG_ENV_VARS: ${{ inputs['config-env-vars'] }} CONFIG_ENV_VARS_INIT: ${{ inputs['config-env-vars-init'] }} CONFIG_VARS_INIT_RANDOM: ${{ inputs['config-vars-init-random'] }} @@ -191,15 +195,6 @@ runs: run: | set -o pipefail - # Docker Compose (and the Defang CLI) natively read the compose file - # list from COMPOSE_FILE, so export that instead of building a - # repeated -f/-f/-f list ourselves. COMPOSE_PATH_SEPARATOR pins the - # separator to ':' regardless of the runner OS. - if [[ -n "$COMPOSE_FILES" ]]; then - export COMPOSE_PATH_SEPARATOR=":" - export COMPOSE_FILE="${COMPOSE_FILES// /:}" - fi - params=() if [[ -n "${{ inputs['mode'] }}" ]]; then params+=("--mode=${{ inputs['mode'] }}") @@ -227,7 +222,6 @@ runs: fi env: COMMAND: ${{ inputs['command'] }} - COMPOSE_FILES: ${{ inputs['compose-files'] }} CONFIG_ENV_VARS: ${{ inputs['config-env-vars'] }} CONFIG_ENV_VARS_INIT: ${{ inputs['config-env-vars-init'] }} CONFIG_VARS_INIT_RANDOM: ${{ inputs['config-vars-init-random'] }} @@ -246,21 +240,20 @@ runs: - name: Deployment summary id: summary - if: ${{ inputs['summary'] == 'true' || (inputs['summary'] == '' && inputs['command'] == 'compose up') }} + if: ${{ inputs['summary'] == 'true' || (inputs['summary'] == '' && (inputs['command'] == 'compose up' || startsWith(inputs['command'], 'compose up '))) }} shell: bash working-directory: ${{ inputs.cwd }} run: | set -o pipefail - # Docker Compose (and the Defang CLI) natively read the compose file - # list from COMPOSE_FILE; see the "Defang $command" step above. - if [[ -n "$COMPOSE_FILES" ]]; then - export COMPOSE_PATH_SEPARATOR=":" - export COMPOSE_FILE="${COMPOSE_FILES// /:}" + if ! command -v jq >/dev/null 2>&1; then + echo "::notice::jq not found; skipping deployment summary" + exit 0 fi - # Fetch the deployed services as JSON. Stack/provider/project come from - # the environment set earlier, so they don't need to be passed again. + # Fetch the deployed services as JSON. Compose files, stack, provider, + # and project come from the environment set earlier, so they don't + # need to be passed again. # Only fall back to an empty array on failure, so a successful run # never yields two concatenated JSON values. if services=$(defang services --json 2>/dev/null); then @@ -288,6 +281,4 @@ runs: echo "_No services found._" fi } >> "$GITHUB_STEP_SUMMARY" - env: - COMPOSE_FILES: ${{ inputs['compose-files'] }}