|
1 | 1 | --- |
2 | 2 | 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. |
4 | 4 | --- |
5 | 5 |
|
6 | 6 | # GitHub Actions |
@@ -147,6 +147,52 @@ as script. |
147 | 147 | - run: echo "${{ github.event.pull_request.title }}" |
148 | 148 | ``` |
149 | 149 |
|
| 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 | + |
150 | 196 | ## Extract non-trivial `run:` scripts into an action |
151 | 197 |
|
152 | 198 | A short `run:` step — a handful of commands wiring tools together — belongs |
@@ -322,3 +368,188 @@ concurrency: |
322 | 368 | than a silent one. |
323 | 369 | - **Give every job and every non-trivial step a `name:`.** Named steps make the |
324 | 370 | 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 | +``` |
0 commit comments