Skip to content

Commit 432d03c

Browse files
MariusStorhaugMarius Storhaug
andauthored
📖 [Docs]: build diagnostics and reporting into the GitHub Actions standard (#13)
Adds a **"Build in logging and diagnostics"** section to the GitHub Actions standard: an action reports on itself so any run is diagnosable from the log it already produced. The section states how an action logs, signals its result, and gates a merge. - **Logs the whole story by default.** Resolved inputs, decisions, external calls, and outputs are logged on every run, each phase wrapped in a `::group::` block so the detail is present yet collapsed. - **Calls out the result with an annotation.** `::notice::` / `::warning::` / `::error::` carry the lines a reader must not miss — above all the final result — with dynamic values escaped. - **Reports the bigger picture in a step summary.** A result larger than a line is GitHub-flavored Markdown in the step summary: the headline in the open, long detail folded into `<details>` blocks, and the same facts exposed as outputs. - **Reports back on the trigger.** Feedback reaches the author through the channel the trigger implies — a pull-request comment, an issue comment, or the step summary — as one upserted comment from the single job that holds write scope. - **Gates merges with a named status check.** Work that must block a merge surfaces as a stable, named check that exits non-zero on a real failure. The examples follow the PSModule action helpers (`LogGroup`, `Write-GitHubNotice`, `Set-GitHubStepSummary`, `Details`). Context: extends the standard from authoring mechanics into how an action communicates its work. --------- Co-authored-by: Marius Storhaug <Marius.Storhaug@dnb.no>
1 parent cc8e543 commit 432d03c

2 files changed

Lines changed: 233 additions & 2 deletions

File tree

‎src/docs/Coding-Standards/GitHub-Actions.md‎

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: GitHub Actions
3-
description: Workflow authoring — SHA pinning, least-privilege permissions, OIDC, secrets handling, and script extraction.
3+
description: Workflow authoring — SHA pinning, least-privilege permissions, OIDC, secrets handling, a PowerShell-first scripting default, script extraction, and diagnostic logging.
44
---
55

66
# GitHub Actions
@@ -147,6 +147,52 @@ as script.
147147
- run: echo "${{ github.event.pull_request.title }}"
148148
```
149149

150+
## Default to PowerShell as the glue language
151+
152+
Between the declarative steps, a workflow always has some glue to run — reading a
153+
value, calling an API, shaping a result. That glue is written in **one language
154+
by default**, so every action reads the same way and reaches for the same helpers
155+
instead of each one inventing its own idiom. **PowerShell is that default.** It
156+
runs cross-platform on every runner, it is held to the
157+
[PowerShell](PowerShell/index.md) standard like any other code, and it carries the
158+
ecosystem's Actions tooling — above all the PSModule `GitHub` module, whose
159+
helpers speak the runner's own workflow-command protocol.
160+
161+
Reach for the options in this order, and drop to the next only when the one above
162+
genuinely cannot serve:
163+
164+
- **First choice — PowerShell with the PSModule `GitHub` module.** Talk to the
165+
runner through the module's commands instead of hand-writing raw workflow
166+
strings: the `Write-GitHub*` family (`Write-GitHubNotice`, `Write-GitHubWarning`,
167+
`Write-GitHubError`) for annotations, `LogGroup` for grouped logging, and
168+
`Set-GitHubStepSummary` for the step summary. The helper escapes dynamic data
169+
for you, keeps the script declarative, and gives every action one vocabulary for
170+
the diagnostics the [logging section](#build-in-logging-and-diagnostics) calls
171+
for.
172+
- **Fallback — plain PowerShell.** When the `GitHub` module is not available or
173+
not warranted — a script that does no runner communication, or one that must run
174+
before the module is installed — stay in PowerShell and write to the log
175+
directly.
176+
- **Last resort — Bash, only when PowerShell cannot be supported.** A context with
177+
no `pwsh` — a minimal container, or a step that must run before PowerShell is on
178+
the image — falls back to Bash. Keep it small and hold it to the same rules,
179+
above all [never expand untrusted input inline](#never-expand-untrusted-input-inline).
180+
181+
```yaml
182+
# Preferred — PowerShell glue; the GitHub module speaks to the runner.
183+
- name: Publish
184+
shell: pwsh
185+
env:
186+
SPACE: ${{ inputs.space }}
187+
DRY_RUN: ${{ inputs.dry-run }}
188+
run: |
189+
LogGroup 'Resolve inputs' {
190+
Write-Host "space = $env:SPACE"
191+
Write-Host "dry-run = $env:DRY_RUN"
192+
}
193+
Write-GitHubNotice -Message "publishing to $env:SPACE" -Title 'Publish'
194+
```
195+
150196
## Extract non-trivial `run:` scripts into an action
151197

152198
A short `run:` step — a handful of commands wiring tools together — belongs
@@ -322,3 +368,188 @@ concurrency:
322368
than a silent one.
323369
- **Give every job and every non-trivial step a `name:`.** Named steps make the
324370
Actions UI and logs readable and make failures easy to locate.
371+
372+
## Build in logging and diagnostics
373+
374+
An action is a black box until it fails. Make every run explain itself — what it
375+
read, what it decided, and what it produced — *by default*, so a failure is
376+
diagnosed from the log you already have rather than from a second run with extra
377+
logging switched on. The craft is keeping all that detail present but out of the
378+
way until someone wants it.
379+
380+
### Log the whole story by default, collapsed into groups
381+
382+
- **Log the resolved inputs, the decisions taken, each external call and its
383+
status, and the outputs produced — on every run.** These are the details a
384+
failure is diagnosed from, so the first run to fail carries enough to diagnose
385+
it. Log a secret's presence, never its value; secret inputs stay masked (see
386+
[Distinguish `vars` from `secrets`](#distinguish-vars-from-secrets)).
387+
- **Force an untrusted value onto a single line before logging it.** The runner
388+
parses every line of stdout, so a value you do not control that carries a
389+
newline followed by `::...` can smuggle in a workflow command. Strip or encode
390+
`\r` / `\n` (or emit the value through a helper) so a logged input cannot break
391+
out into a command — the same untrusted-input rule as
392+
[Never expand untrusted input inline](#never-expand-untrusted-input-inline).
393+
- **Wrap each phase in a `::group::` / `::endgroup::` block.** Grouping keeps the
394+
detail present but collapsed — there in plain sight, one expand away — so the
395+
top level reads as a short list of phases while the depth sits a click beneath
396+
each. Group by phase (`Resolve inputs`, `Publish 42 pages`), one group per
397+
phase, not one per line.
398+
- **Wrap the group markers in a helper so the script stays declarative.**
399+
Emitting the raw `::group::` / `::endgroup::` lines by hand is noisy; a small
400+
wrapper that takes a title and a block keeps the intent visible. A PowerShell
401+
action gets this from the PSModule `GitHub` module — `LogGroup 'phase' { ... }`.
402+
403+
```yaml
404+
- name: Publish
405+
shell: bash
406+
env:
407+
SPACE: ${{ inputs.space }}
408+
DRY_RUN: ${{ inputs.dry-run }}
409+
run: |
410+
echo "::group::Resolve inputs"
411+
echo "space = ${SPACE}"
412+
echo "dry-run = ${DRY_RUN}"
413+
echo "::endgroup::"
414+
415+
echo "::group::Publish"
416+
# ...every step of the actual work, logged here as it happens...
417+
echo "::endgroup::"
418+
```
419+
420+
### Call out the result with an annotation
421+
422+
- **Use `::notice::`, `::warning::`, or `::error::` for the few lines a reader
423+
must not miss** — above all the final result: what was created, or the
424+
pass/fail verdict. Annotations render on the run summary and in the Checks
425+
view, above the collapsed log, so the headline is visible without expanding a
426+
single group.
427+
- **Annotate the outcome, not the progress.** Step-by-step narration belongs in
428+
the grouped log; if every other line is a `::notice::`, the one callout that
429+
matters is lost. Aim to end a run on a single clear annotation.
430+
- **Escape dynamic data in an annotation.** A workflow command is a single line,
431+
so a value carrying `%`, a carriage return, or a newline must be encoded
432+
(`%25`, `%0D`, `%0A`) or it corrupts the command — the same class of risk as
433+
[expanding untrusted input inline](#never-expand-untrusted-input-inline). A
434+
value placed in a command *property* (such as `title=`) needs `:` and `,`
435+
encoded too (`%3A`, `%2C`), since those characters delimit the property list. A
436+
helper that emits the command handles this for you (PowerShell:
437+
`Write-GitHubNotice` / `Write-GitHubError`).
438+
439+
```bash
440+
# Integer counts are safe to interpolate. Escape free-form text (names, messages)
441+
# with %25 / %0D / %0A first, or emit it through a helper such as Write-GitHubNotice.
442+
echo "::notice title=Published::created ${CREATED}, updated ${UPDATED}"
443+
444+
# A blocking failure: annotate, then exit non-zero (see the status check below).
445+
echo "::error title=Publish failed::${FAILED} page(s) rejected"
446+
```
447+
448+
### Report the bigger picture in a step summary
449+
450+
- **When the result is more than a line — a table, per-item counts, a report —
451+
write it to `$GITHUB_STEP_SUMMARY` as GitHub-flavored Markdown.** The step
452+
summary renders on the run's summary page, so the outcome is legible without
453+
opening the log at all.
454+
- **Keep the summary uncluttered by default.** Show the headline — the table or
455+
the verdict — in the open, and tuck long or secondary detail inside
456+
`<details><summary>...</summary>` blocks that stay closed until the reader
457+
opens them. A summary that spills everything inline is as hard to scan as an
458+
ungrouped log.
459+
- **Compose the Markdown with a helper rather than building strings** where you
460+
can. A PowerShell action assembles the summary with the PSModule `Markdown`
461+
module — `Heading`, `Table`, `Details { ... }` — and writes it with the
462+
`GitHub` module's `Set-GitHubStepSummary`.
463+
- **Surface the same facts as outputs.** A URL, a count, or a verdict worth
464+
putting in the summary is also worth an `output` (see
465+
[Generalize the action](#generalize-the-action-drive-behaviour-through-inputs-and-outputs)),
466+
so a calling workflow acts on a value instead of scraping the summary.
467+
468+
```bash
469+
{
470+
echo "## ✅ Documentation publish"
471+
echo ""
472+
echo "| Result | Count |"
473+
echo "| ------- | ----- |"
474+
echo "| Created | ${CREATED} |"
475+
echo "| Updated | ${UPDATED} |"
476+
echo "| Skipped | ${SKIPPED} |"
477+
echo ""
478+
echo "<details><summary>Pages created (${CREATED})</summary>"
479+
echo ""
480+
echo "${CREATED_LIST}" # one entry per line — kept closed until opened
481+
echo ""
482+
echo "</details>"
483+
} >> "$GITHUB_STEP_SUMMARY"
484+
```
485+
486+
### Report back on the triggering PR or issue
487+
488+
The step summary is only seen by someone who opens the run. When the outcome
489+
matters to a person mid-flow — a PR author, an issue reporter — surface the
490+
result where they already are. **Which channel is right depends on the event
491+
that triggered the run**, so make reporting conditional on the trigger rather
492+
than assuming one exists.
493+
494+
- **`pull_request` → a pull request comment.** Post the summary to the PR so the
495+
author sees it in the timeline.
496+
- **`issues` / `issue_comment` → an issue comment.** Reply on the issue that
497+
started the run.
498+
- **`push` / `schedule` / `workflow_dispatch` → the step summary, plus a tracking
499+
issue for a finding worth chasing.** There is no PR or issue in context, so the
500+
step summary stands on its own; a scheduled job that detects a problem can open
501+
or update an issue.
502+
- **Upsert one comment; never post a fresh one per run.** Write a hidden marker
503+
(an HTML comment such as `<!-- publish-summary -->`) into the body, find the
504+
existing comment by that marker, and edit it in place — so a PR pushed ten
505+
times carries one current comment, not ten stale ones.
506+
- **Grant the write scope only on the job that comments.** A PR comment needs
507+
`pull-requests: write`, an issue comment needs `issues: write`; the rest of the
508+
workflow stays read-only. Keep untrusted input out of the comment body (see
509+
[Never expand untrusted input inline](#never-expand-untrusted-input-inline)),
510+
and treat `pull_request_target` with particular care — it runs with a writable
511+
token in the context of untrusted pull request code.
512+
513+
```yaml
514+
jobs:
515+
report:
516+
name: Report
517+
if: github.event_name == 'pull_request'
518+
runs-on: ubuntu-24.04
519+
# Only this job can write to the PR; every other job stays read-only.
520+
permissions:
521+
contents: read # checkout
522+
pull-requests: write # comment on the PR
523+
steps:
524+
# A local action runs from the checked-out repo, so check out first.
525+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
526+
- uses: ./.github/actions/publish-summary # upserts one marked comment
527+
```
528+
529+
### Gate merges with a named status check
530+
531+
- **If a run's result should block merge, it must surface as a named status
532+
check** that branch protection can require. A run that only writes to the log
533+
or a comment cannot hold a pull request; a required check can.
534+
- **Keep the check name stable.** Branch protection matches checks by name, so
535+
renaming the job or workflow silently drops the gate. Pick a clear, permanent
536+
name and treat it as a contract — renaming it later removes the gate without a
537+
trace, so the rename and the branch-protection update must land together.
538+
- **Fail the check for real problems; do not annotate and exit 0.** A gating
539+
action must exit non-zero when it finds a blocking issue — the annotations and
540+
the red summary are for humans, the exit code is what the merge gate reads.
541+
Reserve `::warning::` and `::notice::` for advisory findings that should *not*
542+
hold the pull request.
543+
544+
```yaml
545+
jobs:
546+
publishable:
547+
name: Publishable # the exact string branch protection requires
548+
runs-on: ubuntu-24.04
549+
permissions:
550+
contents: read
551+
steps:
552+
# A local action runs from the checked-out repo, so check out first.
553+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
554+
- uses: ./.github/actions/validate-publishable # exits 1 on a blocker
555+
```

‎src/docs/Coding-Standards/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ The baseline pages apply to all code and come first; the per-language standards
2525
| [Testing](Testing.md) | The executable specification — test-first, locally runnable, deterministic. |
2626
| [Performance](Performance.md) | Scale with the input, measure before optimizing, clarity first. |
2727
| [Security](Security.md) | Least privilege, secret hygiene, and the OWASP baseline. |
28-
| [GitHub Actions](GitHub-Actions.md) | Workflow authoring — SHA pinning, least-privilege permissions, OIDC, secrets handling, and script extraction. |
28+
| [GitHub Actions](GitHub-Actions.md) | Workflow authoring — SHA pinning, least-privilege permissions, OIDC, secrets handling, a PowerShell-first scripting default, script extraction, and diagnostic logging. |
2929
| [Markdown](Markdown.md) | GitHub Flavored Markdown authoring rules enforced by the shared markdownlint configuration. |
3030
| [PowerShell](PowerShell/index.md) | Cross-platform PowerShell 7 — the conventions shared by every script, function, and class, with per-construct standards below. |
3131
| [Terraform](Terraform.md) | Stack layout, version pinning, state and secrets, and the fmt/validate/tflint toolchain. |

0 commit comments

Comments
 (0)