@@ -28,11 +28,15 @@ them to point at different code. A full commit SHA is **immutable**.
2828
2929``` yaml
3030# Correct — immutable SHA; comment carries the readable version
31- - uses : actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32- - uses : actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
31+ - name : Check out the repository
32+ uses : actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
33+
34+ - name : Set up Node
35+ uses : actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
3336
3437# Avoid — mutable tag; the referenced code can change under us
35- - uses : actions/checkout@v6
38+ - name : Check out the repository
39+ uses : actions/checkout@v6
3640` ` `
3741
3842Internal actions follow the same rule.
@@ -122,7 +126,9 @@ jobs:
122126 uses: org/reusable-workflows/.github/workflows/build.yml@<sha> # vX.Y.Z
123127 secrets:
124128 PROPAGATION_TOKEN: ${{ secrets.PROPAGATION_TOKEN }}
129+ ` ` `
125130
131+ ` ` ` yaml
126132# Avoid — forwards every secret; the call site no longer documents what's used
127133jobs:
128134 call:
@@ -139,12 +145,216 @@ as script.
139145
140146` ` ` yaml
141147# Correct — value arrives as an environment variable, not inlined into the shell
142- - env:
148+ - name: Print the PR title
149+ shell: pwsh
150+ env:
143151 TITLE: ${{ github.event.pull_request.title }}
144- run: echo "$TITLE"
152+ run: Write-Host $env:TITLE
153+
154+ # Avoid — attacker-controlled title is spliced into the script and executed
155+ - name: Print the PR title
156+ shell: pwsh
157+ run: Write-Host "${{ github.event.pull_request.title }}"
158+ ` ` `
159+
160+ # # Structure work into jobs and steps
161+
162+ A workflow is a set of **jobs**, and each job is a sequence of **steps**. The two
163+ are not interchangeable : steps in a job share one runner — the same filesystem
164+ and environment, running in order by default — while every job gets a **fresh
165+ runner** and runs in parallel with its siblings unless told to wait. Reach for a
166+ step by default; add a job only when a step cannot give you what you need.
167+
168+ - **Default to steps within one job.** Work that is sequential and shares state —
169+ check out, build, then test what you just built — belongs in a single job as
170+ ordered steps. They share the workspace, so each step sees the files the last
171+ one produced without copying anything, and the job reads top to bottom as one
172+ story.
173+ - **Add a job to run independent work on its own runner.** Two pieces of
174+ work with no data dependency between them — a lint pass and a security scan —
175+ finish sooner as two jobs on two runners than as serial steps on one, each
176+ isolated with its own environment and permissions. Steps within a single job
177+ can now run concurrently too, but that is newer and shares one runner (see
178+ [Parallel steps are new and not yet a default](#parallel-steps-are-new-and-not-yet-a-default)).
179+ Add ordering with `needs:` only where a real dependency exists.
180+ - **Add a job to draw a permission boundary.** The job is the unit that
181+ ` permissions:` scopes (see
182+ [Grant least-privilege permissions](#grant-least-privilege-permissions)). When
183+ one slice of the work needs a wider scope — a step that comments on the pull
184+ request needs `pull-requests : write` — isolate it in its own job so the rest of
185+ the workflow stays read-only instead of raising the floor for everything.
186+ - **Add a job for a different runner or a deployment environment.** A job pins
187+ its own `runs-on:` and can target an `environment:` with its own protection
188+ rules, approvals, and secrets. Work that must run on a different image, or
189+ behind a manual deploy gate, is a separate job.
190+ - **Add a job when the result must gate merge.** Branch protection requires
191+ status checks by job name (see
192+ [Gate merges with a named status check](#gate-merges-with-a-named-status-check)),
193+ so a result that blocks a pull request is its own job.
194+ - **Add a job to call a reusable workflow.** A reusable workflow is invoked as a
195+ job-level `uses:`, never as a step (see
196+ [Choose an action or a reusable workflow](#choose-an-action-or-a-reusable-workflow)).
197+
198+ Weigh these against what a job costs. A new job is a fresh runner : it checks out
199+ again, warms its own caches, and shares no filesystem with its siblings — data
200+ crosses a job boundary only through `outputs` or an uploaded artifact. So
201+ splitting sequential, state-sharing work across jobs buys nothing and adds
202+ handoff overhead; parallelism across separate runners, an isolated permission
203+ scope, a distinct environment, and merge gating are what earn a new job.
204+
205+ ` ` ` yaml
206+ permissions: {}
207+
208+ jobs:
209+ build:
210+ name: Build and test
211+ runs-on: ubuntu-24.04
212+ permissions:
213+ contents: read
214+ steps:
215+ # Sequential, state-sharing work belongs in one job as ordered steps.
216+ - name: Check out the repository
217+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
218+
219+ - name: Build
220+ shell: pwsh
221+ run: ./build.ps1 # writes artifacts into the workspace
222+
223+ - name: Test
224+ shell: pwsh
225+ run: ./test.ps1 # reads them from the same workspace — no handoff
226+
227+ report:
228+ name: Report
229+ needs: build # runs only after build succeeds
230+ if: github.event_name == 'pull_request'
231+ runs-on: ubuntu-24.04
232+ permissions:
233+ contents: read
234+ pull-requests: write # a wider scope, isolated to this one job
235+ steps:
236+ - name: Check out the repository
237+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
145238
146- # Avoid — attacker-controlled title is executed as shell
147- - run: echo "${{ github.event.pull_request.title }}"
239+ - name: Publish the summary comment
240+ uses: ./.github/actions/publish-summary
241+ ` ` `
242+
243+ # ## Parallel steps are new and not yet a default
244+
245+ Every step in a job historically ran in sequence — each starting only once the
246+ last finished — and that sequential model is what the rest of this section
247+ assumes. GitHub has since added **concurrent steps** within a single job, through
248+ new workflow keywords :
249+
250+ - `background : true` starts a step asynchronously and continues straight to the
251+ next step.
252+ - ` wait` / `wait-all` block until one, several, or all prior background steps
253+ finish.
254+ - ` cancel` stops a background step once it is no longer needed — for instance a
255+ service started only for the steps that run alongside it.
256+ - `parallel` runs a group of steps concurrently and then waits for them : the
257+ convenience form of "start these together, then carry on".
258+
259+ This covers patterns that used to force a second job or a shell backgrounding
260+ hack (`&`) : independent work run at once on one runner, a background service that
261+ later steps use and then shut down, or non-blocking work — uploading telemetry
262+ while packaging continues — overlapping the steps after it. Because the steps
263+ share the runner, they also share its filesystem, which a separate job does not.
264+
265+ Adopt it deliberately :
266+
267+ - **Prefer a separate job for ordinary parallelism.** When independent work does
268+ not need a shared workspace, two jobs stay the clearer, better-isolated choice —
269+ separate runners, permissions, and logs. Reach for concurrent steps only when
270+ the work genuinely benefits from one shared runner.
271+ - **Confirm the toolchain supports the keywords first.** The feature is recent,
272+ so the pinned [`actionlint` / `zizmor`](#toolchain) versions and the runner
273+ image in use may not yet validate or run the new keywords; verify before
274+ relying on it. Expect interleaved concurrent steps to be harder
275+ to follow, so keep the [grouped-logging discipline](#build-in-logging-and-diagnostics).
276+
277+ Until it has settled in the ecosystem, treat concurrent steps as a tool for the
278+ few cases that need a shared runner, and let a separate job remain the default
279+ answer to "these should run in parallel". See the
280+ [workflow syntax reference](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions)
281+ for exact usage.
282+
283+ # # Choose an action or a reusable workflow
284+
285+ Both an **action** and a **reusable workflow** package automation for reuse, but
286+ at different granularities, and they plug in at different levels. Match the unit
287+ to the thing being reused.
288+
289+ - **An action is a reusable unit of *steps*.** It plugs into a job as a step
290+ (`uses:` at step level), runs on the caller's runner inside the caller's job —
291+ sharing that job's filesystem and environment — and does **one well-defined
292+ thing** through an `inputs` / `outputs` interface. Several actions compose
293+ within a single job. This is the smaller unit and the common case;
294+ [Extract non-trivial `run:` scripts into an action](#extract-non-trivial-run-scripts-into-an-action)
295+ is entirely about authoring one.
296+ - **A reusable workflow is a reusable unit of *jobs*.** It is called as a
297+ job-level `uses:` (`org/repo/.github/workflows/build.yml@<sha>`) and brings its
298+ own jobs, runners, `permissions`, and `secrets` contract — a whole pipeline,
299+ not a single task. Use it to standardize a **multi-job process** several callers
300+ should run the same way : a shared build-test-publish flow, a common release
301+ pipeline.
302+
303+ Reach for the smallest unit that fits :
304+
305+ - **Choose an action** to package a **step-level capability** — one task used
306+ inside a job (`link-check`, `publish-docs`, `start-cloud-agent`). It composes
307+ with the steps around it and hands results back as outputs.
308+ - **Choose a reusable workflow** to package an **end-to-end, multi-job process** —
309+ its own job graph, `needs:` ordering, per-job permissions, and environment
310+ gates — that several repositories should run identically. When the thing worth
311+ sharing is the *whole pipeline* rather than one step of it, the workflow is the
312+ unit that carries the jobs with it.
313+ - **Do not wrap a single task in a reusable workflow.** A workflow drags a job
314+ and a runner behind it; if the reused logic is one step's worth, an action is
315+ lighter, composes with the surrounding steps, and returns outputs to the
316+ calling step directly. Equally, do not force a multi-job pipeline into one
317+ action — an action cannot span jobs or set per-job permissions.
318+
319+ Both follow the same lifecycle : **start as a local action or workflow**,
320+ referenced by path (`./.github/...`) so it runs at the checked-out commit, and
321+ **promote it to a standalone repository only once a second consumer appears**
322+ (see [Start local; promote when it is reused](#start-local-promote-when-it-is-reused)).
323+ Once standalone — like any third-party dependency — it is **consumed by full
324+ commit SHA** (see
325+ [Pin every action to a full commit SHA](#pin-every-action-to-a-full-commit-sha)).
326+ A reusable workflow additionally takes its secrets **explicitly by name, never
327+ `secrets : inherit`** (see
328+ [Distinguish `vars` from `secrets`](#distinguish-vars-from-secrets)).
329+
330+ ` ` ` yaml
331+ permissions: {}
332+
333+ jobs:
334+ # An action — a step-level capability, composed inside a job.
335+ docs:
336+ name: Publish docs
337+ runs-on: ubuntu-24.04
338+ permissions:
339+ contents: read
340+ steps:
341+ - name: Check out the repository
342+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
343+
344+ - name: Publish the docs
345+ uses: ./.github/actions/publish-docs # one task; returns outputs
346+ with:
347+ space: ENG
348+
349+ # A reusable workflow — a whole multi-job process, called as a job.
350+ ci:
351+ name: CI
352+ uses: org/reusable-workflows/.github/workflows/build.yml@<sha> # vX.Y.Z
353+ permissions:
354+ contents: read
355+ id-token: write
356+ secrets:
357+ PROPAGATION_TOKEN: ${{ secrets.PROPAGATION_TOKEN }} # explicit, by name
148358` ` `
149359
150360# # Default to PowerShell as the glue language
@@ -361,13 +571,27 @@ concurrency:
361571 cancel-in-progress : false
362572` ` `
363573
364- ## Pin the runner and name everything
574+ ## Pin the runner, name everything, and space it out
365575
366576- **Pin ` runs-on` to a specific runner image** (`ubuntu-24.04`) rather than
367577 ` ubuntu-latest` , so a runner upgrade is a deliberate, reviewable change rather
368578 than a silent one.
369- - **Give every job and every non-trivial step a `name:`.** Named steps make the
370- Actions UI and logs readable and make failures easy to locate.
579+ - **Give every job and every step a `name:`** — not only the non-trivial ones.
580+ Left unnamed, a step is labelled by whatever GitHub derives from its `run` or
581+ `uses` : the full pinned SHA for an action (`Run actions/checkout@de0fac…`), or
582+ the truncated first line of a script — both hard to read, and both changing
583+ whenever the command does. A written `name:` is the exact text shown in the
584+ Actions UI, the logs, and the Checks view, so a step **reads the same in the
585+ code as in the portal**, stays a **stable handle** for links and log searches
586+ when the command underneath it changes, and names a failure by intent rather
587+ than by a decoded command line. Under the
588+ [SHA-pinning rule](#pin-every-action-to-a-full-commit-sha) it matters all the
589+ more : an unnamed action step wears its 40-character SHA as its label.
590+ - **Separate each job and each step with a single blank line.** One blank line
591+ between consecutive steps, and one between consecutive jobs, makes every unit a
592+ self-contained block that is easy to scan, reorder, and read in a diff. Use
593+ exactly one — with none, adjacent steps blur together; with several, the file
594+ turns gappy.
371595
372596# # Build in logging and diagnostics
373597
@@ -402,19 +626,19 @@ way until someone wants it.
402626
403627` ` ` yaml
404628- name: Publish
405- shell: bash
629+ shell: pwsh
406630 env:
407631 SPACE: ${{ inputs.space }}
408632 DRY_RUN: ${{ inputs.dry-run }}
409633 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::"
634+ LogGroup ' Resolve inputs' {
635+ Write-Host "space = $env: SPACE"
636+ Write-Host "dry-run = $env: DRY_RUN"
637+ }
638+
639+ LogGroup ' Publish 42 pages' {
640+ # ...each page logged here as it is published ...
641+ }
418642` ` `
419643
420644# ## Call out the result with an annotation
@@ -436,13 +660,13 @@ way until someone wants it.
436660 helper that emits the command handles this for you (PowerShell :
437661 ` Write-GitHubNotice` / `Write-GitHubError`).
438662
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}"
663+ ` ` ` powershell
664+ # The GitHub module escapes dynamic data for you — no manual %25 / %0D / %0A.
665+ Write-GitHubNotice -Title 'Published' -Message "created $($created.Count), updated $($updated.Count)"
443666
444- # A blocking failure: annotate, then exit non-zero (see the status check below).
445- echo "::error title=Publish failed::${FAILED} page(s) rejected"
667+ # A blocking failure: annotate, then throw so the step exits non-zero (see the status check below).
668+ Write-GitHubError -Title 'Publish failed' -Message "$($rejected.Count) page(s) rejected"
669+ throw "$($rejected.Count) page(s) rejected"
446670` ` `
447671
448672# ## Report the bigger picture in a step summary
@@ -465,22 +689,20 @@ echo "::error title=Publish failed::${FAILED} page(s) rejected"
465689 [Generalize the action](#generalize-the-action-drive-behaviour-through-inputs-and-outputs)),
466690 so a calling workflow acts on a value instead of scraping the summary.
467691
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"
692+ ` ` ` powershell
693+ Set-GitHubStepSummary -Summary (
694+ Heading 2 '✅ Documentation publish' {
695+ Table {
696+ [pscustomobject]@{ Result = 'Created'; Count = $created.Count }
697+ [pscustomobject]@{ Result = 'Updated'; Count = $updated.Count }
698+ [pscustomobject]@{ Result = 'Skipped'; Count = $skipped.Count }
699+ }
700+
701+ Details "Pages created ($($created.Count))" {
702+ $created -join "` n" # one title per line — kept closed until opened
703+ }
704+ }
705+ )
484706```
485707
486708### Report back on the triggering PR or issue
@@ -522,8 +744,11 @@ jobs:
522744 pull-requests : write # comment on the PR
523745 steps :
524746 # 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
747+ - name : Check out the repository
748+ uses : actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
749+
750+ - name : Publish the summary comment
751+ uses : ./.github/actions/publish-summary # upserts one marked comment
527752` ` `
528753
529754### Gate merges with a named status check
@@ -550,6 +775,9 @@ jobs:
550775 contents: read
551776 steps:
552777 # 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
778+ - name: Check out the repository
779+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
780+
781+ - name: Validate that the docs are publishable
782+ uses: ./.github/actions/validate-publishable # exits 1 on a blocker
555783` ` `
0 commit comments