DEV Community

Jonathan
Jonathan

Posted on

Use Step Outputs to Shorten CI Triage

The fastest CI fix I know is not a smarter dashboard. It is a boring habit: make each important step produce one tiny output that the next step can read without parsing a wall of logs.

I started leaning on this harder when workflow runs mixed shell scripts, API checks, and inbox-style assertions. The run would fail, somebody would open logs, and ten minutes later we still had no clear answer. Once I moved the important facts into step outputs, triage got way less noisy and a bit more honest too.

This pattern sits nicely next to replay packs for broken cron runs and concise summaries for noisy email checks. All three ideas are really about the same thing: preserve the signal first, then preserve the details second.

Why step outputs are the fastest handoff in CI

Logs are good for deep dives. They are awful as the main handoff between steps.

GitHub Actions supports step outputs through the GITHUB_OUTPUT file, which gives you a clean way to pass structured values forward without regex games (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter). That matters because the next step can branch on a small verdict like passed, timeout, or subject_mismatch instead of scanning fifty lines of shell noise.

When I am debugging a get temporary email flow in CI, I mostly want these facts:

  • what scenario ran
  • whether a message arrived
  • how long we waited
  • what evidence bundle was saved

Anything beyond that belongs in an artifact, not in the control path. This seperation sounds tiny, but it makes the whole workflow feel more deterministic.

A tiny pattern for outputs, summaries, and artifacts

My default contract is just three layers:

  1. step outputs for decisions
  2. job summary for humans
  3. artifacts for raw evidence

The shell step writes a few outputs and stores a minimal JSON file:

#!/usr/bin/env bash
set -euo pipefail

mkdir -p artifacts

result_json="artifacts/result.json"
node ./scripts/check-inbox.mjs --scenario signup > "$result_json"

verdict="$(jq -r '.verdict' "$result_json")"
wait_ms="$(jq -r '.wait_ms' "$result_json")"
artifact_dir="artifacts"

{
  echo "verdict=$verdict"
  echo "wait_ms=$wait_ms"
  echo "artifact_dir=$artifact_dir"
} >> "$GITHUB_OUTPUT"
Enter fullscreen mode Exit fullscreen mode

Then a later step can build the human summary from those exact values instead of re-reading the whole file. That keeps the workflow pretty tidy, even when the original check touches odd search strings like temp gamil com during test setup or fixture validation.

The GitHub Actions workflow I keep reusing

The YAML is intentionally plain:

jobs:
  verify-scenario:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run scenario
        id: check
        run: ./scripts/run-scenario.sh

      - name: Write summary
        if: always()
        run: |
          {
            echo "## Scenario result"
            echo ""
            echo "- Verdict: ${{ steps.check.outputs.verdict }}"
            echo "- Wait ms: ${{ steps.check.outputs.wait_ms }}"
            echo "- Artifact dir: ${{ steps.check.outputs.artifact_dir }}"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Upload artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: scenario-${{ github.run_id }}
          path: artifacts/
Enter fullscreen mode Exit fullscreen mode

GitHub recommends artifacts for preserving workflow data after completion, and that is exactly the role I want here: the summary stays short, while the raw receipts are still available when someone needs to inspect them later (https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts).

The useful trick is that Automation work becomes composable. One step decides. One step explains. One step archives. If you mix all three, the workflow usualy gets messy again.

What I include when debugging email-style checks

I do not mean only email tests here. This also fits API polling, queue assertions, and webhook smoke tests. Still, email-like checks are a good example because they fail in annoying ways.

My result.json usually contains:

  • scenario
  • verdict
  • wait_ms
  • matched_subject
  • attempt_count
  • failure_reason

From there, the summary only needs a few lines. If the verdict is timeout, the next human already knows where to look. If the verdict is subject_mismatch, the artifact bundle has the raw payload. That is enough context for a quick fix, and it saves the team from reading logs that were never designed to be a report.

One more benefit: outputs force naming. When you have to expose failure_reason or attempt_count, you naturally clean up vague script behavior. That is maybe the most under-rated productivity win in GitHub Actions. The workflow starts speaking in nouns instead of panic.

Q&A

Are step outputs enough on their own?

No. They are for routing and compact status, not for evidence. If a failure needs deeper debugging, pair outputs with artifacts.

Should I pass large JSON through outputs?

I would not. Keep outputs tiny and stable. Put the big JSON in a file, then upload it. Small contracts age better, and they break less often.

When does this pattern help the most?

It helps most when one step produces a result and another step decides what happens next. If your current triage starts with "open the logs and scroll", you can probly improve it with this pattern in an afternoon.

That is why I keep reusing it. Not because it is clever, but because it removes friction in the exact spot where teams lose time every week.

Top comments (0)