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.
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/checkoutv7.0.1 is pinned to3d3c42e5aac5ba805825da76410c181273ba90b1. -
actions/setup-pythonv7.0.0 is pinned to5fda3b95a4ea91299a34e894583c3862153e4b97; the lab requests Python 3.13. -
actions/upload-artifactv7.0.1 is pinned to043fb46d1a93c77aae656e7c1c64a875d1fc6a0a. -
GitHub CLI is used from your local authenticated shell for
read/dispatch/rerun inspection; record
gh --versionrather 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
- Checkout: binds any workflow-file/hash inspection to the exact event revision; credentials are not persisted.
- Evidence capture: converts event/run state into a small JSON allowlist. The raw sample becomes length/hash only.
- Summary: creates a human view from already-bounded state; the displayed demo snippet is length-bounded and HTML-escaped.
-
Synthetic check: records the real process exit
code without using
continue-on-error. - Annotation: points a human to the failing workflow location but does not pretend to be the failure mechanism.
- Artifact upload: runs even after earlier logical failure so first-failure evidence survives.
- 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_DEBUGvariables instead of usinggh 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.
Knowledge check
Why does the check step capture its exit code instead of using
continue-on-error?
So evidence can be uploaded before the final gate while the original non-zero result is still explicitly preserved and ultimately fails the job.
Why is the raw sample passed via env?
It prevents event-derived text from being interpolated into shell source and allows ordinary shell quoting/data handling.
What should remain the same between attempt 1 and a debug rerun?
Run ID, original SHA/ref and dispatch/event identity; run_attempt changes.
Why does changing mode=fail to
mode=pass need a new run?
Reruns repeat the original event/ref/SHA and inputs; a corrected dispatch is a new event/run.
Where should a 20 MB machine report go?
Into an artifact or approved external telemetry store, not a step summary or giant log dump.
Official references and version notes
- Workflow commands — Current commands, annotations, log groups, masking and GITHUB_STEP_SUMMARY behavior/limits.
- Enable debug logging — ACTIONS_STEP_DEBUG, ACTIONS_RUNNER_DEBUG and runner-diagnostic log behavior.
- Re-run workflows and jobs — Rerun identity, attempt behavior, debug reruns and privilege semantics.
- Monitor workflows — Workflow graph, logs, job timing and troubleshooting entry points.
- REST: workflow runs — Run metadata and exact run-attempt log download endpoints.
- REST: workflow jobs — Job metadata and job-log endpoints.
- Variables reference — GITHUB_STEP_SUMMARY, run/attempt and workflow metadata variables.
- Repository Actions settings — Artifact/log retention and repository-level Actions settings.
- Self-hosted runner troubleshooting — Runner_/Worker_ diagnostic files, connectivity checks and external preservation guidance.
- GitHub CLI: gh run rerun — Current --debug/--failed/--job rerun controls.
- GitHub CLI: gh run view — Attempt-aware run inspection and JSON/log views.
- actions/checkout v7.0.1 — Full-SHA pin used by executable examples.
- actions/setup-python v7.0.0 — Full-SHA pin for Python 3.13 used in the labs.
- actions/upload-artifact v7.0.1 — Full-SHA pin for retained machine-readable evidence.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.