Chapter 30Lesson 02~255 minutes

Workflow Logs, Step Summaries, Debug Logging, Annotations, and Observability: Guided Hands-On Workflow

Instrument a disposable workflow with safe summaries, fixed annotations, grouped logs, machine-readable evidence and an on-demand debug rerun, then correlate attempts with gh and the Actions API.

Hands-ongh CLIDebug rerunSafe renderingCorrelation

Learning objectives

  • Create a disposable workflow that emits separate human and machine-readable evidence.
  • Pass workflow-dispatch input through environment variables rather than interpolating it into shell source.
  • Preserve a synthetic failure artifact before the final job gate returns a failing exit status.
  • Use gh/API to correlate run ID, attempt, source SHA, job/step conclusions and debug state.
  • Perform an on-demand debug rerun without confusing it with a run from corrected source/input.

1. Scenario: diagnose without dumping the world

Create a disposable public or private repository named gha-observability-lab. The workflow accepts a synthetic mode plus a text sample. The sample is intentionally treated as untrusted: it enters the process through an environment variable, the workflow logs only its derived metadata, and the summary displays at most a bounded HTML-escaped snippet. No secret, cloud account, self-hosted runner or paid feature is required.

The first dispatch uses mode=fail. The workflow captures an exit code without swallowing it, uploads evidence under if: always(), creates a fixed annotation, then fails at the final gate. That ordering proves that “artifact survived failure” and “job failed” are separate states.

2. Preflight and assumptions

  • Use only a disposable repository you are authorized to change. Do not run this lab in employer/customer repositories.
  • GitHub.com behavior verified September 10, 2026; runner label is ubuntu-24.04.
  • actions/checkout v7.0.1 is pinned to 3d3c42e5aac5ba805825da76410c181273ba90b1.
  • actions/setup-python v7.0.0 is pinned to 5fda3b95a4ea91299a34e894583c3862153e4b97; the lab requests Python 3.13.
  • actions/upload-artifact v7.0.1 is pinned to 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a.
  • GitHub CLI is used from your local authenticated shell for read/dispatch/rerun inspection; record gh --version rather than assuming the hosted image version.
  • The sample input is test text only. Never paste a real credential, token, private source fragment or regulated data.
gh auth status
gh --version
git --version
# Confirm the current repository before every mutating gh command.
gh repo view --json nameWithOwner,url,visibility

3. Install the observable workflow

Create .github/workflows/observability-lab.yml with the following complete workflow. Notice that the top-level token starts at permissions: {} and the single job requests only read access needed for checkout and Actions metadata. The workflow never prints the token or whole contexts.

name: Observability lab
on:
  workflow_dispatch:
    inputs:
      mode:
        description: "Synthetic result to exercise"
        type: choice
        required: true
        options: [fail, pass]
      sample:
        description: "Untrusted demo text; never treat as shell source"
        type: string
        required: false
        default: "hello <observer>"

permissions: {}

jobs:
  observe:
    name: Observe synthetic check
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      actions: read
    steps:
      - name: Checkout exact event revision
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up pinned Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: "3.13"

      - name: Capture bounded run evidence
        id: evidence
        shell: bash
        env:
          LAB_MODE: ${{ inputs.mode }}
          RAW_SAMPLE: ${{ inputs.sample }}
        run: |
          set -euo pipefail
          python - <<'PY'
          import hashlib, json, os, platform
          raw = os.environ.get("RAW_SAMPLE", "")
          data = {
            "event": os.environ["GITHUB_EVENT_NAME"],
            "sha": os.environ["GITHUB_SHA"],
            "ref": os.environ["GITHUB_REF"],
            "run_id": os.environ["GITHUB_RUN_ID"],
            "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"],
            "mode": os.environ["LAB_MODE"],
            "sample_length": len(raw),
            "sample_sha256": hashlib.sha256(raw.encode()).hexdigest(),
            "python": platform.python_version(),
          }
          with open("evidence.json", "w", encoding="utf-8") as f:
              json.dump(data, f, indent=2, sort_keys=True)
          PY
          printf '::group::Bounded run identity\n'
          printf 'sha=%s\n' "$GITHUB_SHA"
          printf 'run=%s attempt=%s mode=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$LAB_MODE"
          python --version
          printf '::endgroup::\n'
          printf '::notice title=Observability lab::Bounded run evidence captured; raw sample is not logged.\n'

      - name: Write safe human summary
        shell: bash
        env:
          RAW_SAMPLE: ${{ inputs.sample }}
        run: |
          set -euo pipefail
          python - <<'PY'
          import hashlib, html, json, os
          data = json.load(open("evidence.json", encoding="utf-8"))
          raw = os.environ.get("RAW_SAMPLE", "")[:160]
          safe = html.escape(raw, quote=True)
          with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
              f.write("## Observability lab\n\n")
              f.write(f"- source SHA: `{data['sha']}`\n")
              f.write(f"- run / attempt: `{data['run_id']} / {data['run_attempt']}`\n")
              f.write(f"- synthetic mode: `{data['mode']}`\n")
              f.write(f"- sample sha256: `{data['sample_sha256']}`\n\n")
              f.write("Bounded, HTML-escaped sample (never a secret):\n\n")
              f.write(f"<pre><code>{safe}</code></pre>\n")
          PY

      - name: Run synthetic validation and preserve exit code
        id: check
        shell: bash
        env:
          LAB_MODE: ${{ inputs.mode }}
        run: |
          set -u
          set +e
          python - <<'PY' >check.log 2>&1
          import os, sys
          mode = os.environ["LAB_MODE"]
          print(f"synthetic_check mode={mode}")
          if mode == "fail":
              print("expected synthetic validation failure")
              sys.exit(17)
          print("synthetic validation passed")
          PY
          rc=$?
          set -e
          printf 'exit_code=%s\n' "$rc" >> "$GITHUB_OUTPUT"
          printf 'check_exit_code=%s\n' "$rc"

      - name: Annotate synthetic failure
        if: ${{ steps.check.outputs.exit_code != '0' }}
        shell: bash
        run: |
          echo "::error file=.github/workflows/observability-lab.yml,title=Synthetic validation::The disposable check failed; inspect the preserved evidence artifact."

      - name: Record debug state without credentials
        shell: bash
        env:
          IS_DEBUG: ${{ runner.debug }}
        run: |
          printf 'runner_debug=%s\n' "${IS_DEBUG:-0}" >> evidence.json.debug
          echo "::debug::Debug is enabled; only bounded non-secret metadata is emitted."

      - name: Upload machine-readable evidence
        if: ${{ always() }}
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: observability-evidence-${{ github.run_id }}-${{ github.run_attempt }}
          path: |
            evidence.json
            evidence.json.debug
            check.log
          retention-days: 7

      - name: Final result gate
        if: ${{ always() }}
        shell: bash
        env:
          CHECK_RC: ${{ steps.check.outputs.exit_code }}
        run: |
          set -euo pipefail
          if [[ "${CHECK_RC:-99}" != "0" ]]; then
            echo "synthetic validation failed with exit code ${CHECK_RC:-unknown}" >&2
            exit "${CHECK_RC:-1}"
          fi

4. Why the workflow is ordered this way

  1. Checkout: binds any workflow-file/hash inspection to the exact event revision; credentials are not persisted.
  2. Evidence capture: converts event/run state into a small JSON allowlist. The raw sample becomes length/hash only.
  3. Summary: creates a human view from already-bounded state; the displayed demo snippet is length-bounded and HTML-escaped.
  4. Synthetic check: records the real process exit code without using continue-on-error.
  5. Annotation: points a human to the failing workflow location but does not pretend to be the failure mechanism.
  6. Artifact upload: runs even after earlier logical failure so first-failure evidence survives.
  7. Final gate: converts the preserved exit code back into a job failure.

5. Dispatch the intentional failure and capture its identity

# Run from the default branch that contains the workflow.
gh workflow run observability-lab.yml \
  -f mode=fail \
  -f 'sample=hello <observer> [link](https://example.invalid/)'

# Find the exact run. Record the ID instead of operating on "latest" afterwards.
gh run list \
  --workflow observability-lab.yml \
  --limit 5 \
  --json databaseId,attempt,event,headSha,status,conclusion,url

RUN_ID=REPLACE_WITH_EXACT_DATABASE_ID
gh run watch "$RUN_ID" --exit-status || true

Expected observation: the run concludes failure; the annotation points to the synthetic validation; an artifact named with the exact run ID/attempt exists; and the human summary shows the SHA, run/attempt, mode and sample fingerprint without exposing the sample in normal logs. Preserve the URL and ID before doing anything else.

6. Preserve attempt 1 before rerunning

mkdir -p evidence/run-$RUN_ID/attempt-1

gh run view "$RUN_ID" --attempt 1 \
  --json attempt,conclusion,createdAt,headSha,jobs,url \
  > "evidence/run-$RUN_ID/attempt-1/run.json"

gh run view "$RUN_ID" --attempt 1 --log \
  > "evidence/run-$RUN_ID/attempt-1/workflow.log"

gh run download "$RUN_ID" \
  -p 'observability-evidence-*' \
  -D "evidence/run-$RUN_ID/attempt-1/artifacts"

# API view of the same exact resource.
gh api \
  -H 'Accept: application/vnd.github+json' \
  -H 'X-GitHub-Api-Version: 2026-03-10' \
  "repos/{owner}/{repo}/actions/runs/$RUN_ID" \
  > "evidence/run-$RUN_ID/attempt-1/api-run.json"

Replace {owner}/{repo} with the exact disposable repository. Do not use curl -v around authenticated endpoints: verbose HTTP output can expose headers and is unnecessary here.

7. Rerun the same source with debug logging

Now use a diagnostic rerun. This is intentionally not a correction: it repeats the same source/ref/input so you can compare ordinary attempt 1 with debug attempt 2. The failure should remain deterministic.

gh run rerun "$RUN_ID" --failed --debug
gh run watch "$RUN_ID" --exit-status || true

gh run view "$RUN_ID" --attempt 2 \
  --json attempt,conclusion,createdAt,headSha,jobs,url \
  > "evidence/run-$RUN_ID/attempt-2.json"

gh run view "$RUN_ID" --attempt 2 --log \
  > "evidence/run-$RUN_ID/attempt-2.log"

Expected observation: run ID remains the same, attempt becomes 2, source SHA remains the same and the synthetic failure remains. Debug detail increases. Downloading the full attempt log archive also exposes runner diagnostic files for a debug rerun. Review those files before sharing them.

8. Correct the invocation with a new run

Because a rerun cannot change the original dispatch inputs, correcting mode=fail to mode=pass requires a new dispatch—and therefore a new run ID. This is the evidence-safe distinction between a rerun attempt and a corrected invocation.

gh workflow run observability-lab.yml \
  -f mode=pass \
  -f 'sample=hello <observer> [link](https://example.invalid/)'

gh run list --workflow observability-lab.yml --limit 5 \
  --json databaseId,attempt,headSha,conclusion,url

PASS_RUN_ID=REPLACE_WITH_EXACT_SUCCESS_RUN_ID
gh run watch "$PASS_RUN_ID" --exit-status
gh run view "$PASS_RUN_ID" --json attempt,conclusion,headSha,jobs,url

Verify independently that the new run has attempt=1, its own run ID, and a successful final gate. If you instead fixed repository code, commit the repair and prove that the new run’s headSha equals the fixed commit.

9. Safe rendering rule for untrusted content

The workflow deliberately keeps attacker-controlled text out of workflow-command syntax and ordinary logs. It passes the value through env, hashes the full value, and renders only a bounded HTML-escaped snippet inside <pre><code>. This prevents the sample from becoming an active Markdown link/image or closing the code block with raw HTML. Do not do this with secrets: secrets belong outside summaries entirely.

Do not “test redaction” with a real secret. If you want to learn add-mask, generate a disposable fake marker, register it before any other output, and still avoid printing it. A mask is not a vault and cannot repair already-disclosed output.

10. Hosted logs versus self-hosted diagnostics

Surface GitHub-hosted job Self-hosted/ARC job
Step log GitHub run log GitHub run log
Debug rerun archive Includes runner diagnostic files when enabled Includes GitHub-collected diagnostics when enabled
Host application log Host lifecycle is GitHub-managed Local _diag/Runner_* belongs to runner host
Job worker log GitHub-hosted detail via debug archive Local _diag/Worker_* correlates to jobs
Ephemeral teardown GitHub-managed Forward logs externally before ephemeral runner/pod disappears

The mandatory lab stays on GitHub-hosted runners. If you already own an authorized self-hosted test runner, inspect _diag read-only; do not register a new production runner merely for this chapter.

11. Challenge: choose the evidence layer

A test produces 20 MB of machine-readable JSON and one 140-character user-controlled test name. Operators want a short run overview and an external dashboard wants the complete JSON. Choose where each belongs and justify the security boundary. A strong answer puts bounded escaped metadata in the summary, uses an artifact or approved telemetry sink for the JSON, keeps the failure exit code authoritative, and correlates both by run/attempt/SHA.

12. Cleanup and rollback

  • Keep the evidence folder until you have compared attempt 1, debug attempt 2 and the corrected run.
  • Do not delete the failed run merely because it is red; it is the first-failure record.
  • If you enabled repository-level ACTIONS_STEP_DEBUG/ACTIONS_RUNNER_DEBUG variables instead of using gh run rerun --debug, turn them off after the disposable investigation.
  • Delete only the disposable repository when the lab is finished and evidence retention is no longer required.

13. Lesson summary

A useful Actions observability workflow separates signal generation from failure semantics and from evidence retention. The lab proved that a fixed annotation, safe summary, machine artifact, exact run metadata and a failing exit code each have different jobs—and that a debug rerun is an attempt of the same source, not proof of a later fix.

Next lesson

Workflow Logs, Step Summaries, Debug Logging, Annotations, and Observability: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why does the check step capture its exit code instead of using continue-on-error?

Why is the raw sample passed via env?

What should remain the same between attempt 1 and a debug rerun?

Why does changing mode=fail to mode=pass need a new run?

Where should a 20 MB machine report go?

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.