Skip to content

Commit 81edaa6

Browse files
📖 [Docs]: add job-vs-step and action-vs-reusable-workflow guidance to the GitHub Actions standard (#17)
## What Adds two decision-oriented sections to the **GitHub Actions** coding standard (`src/docs/Coding-Standards/GitHub-Actions.md`), answering two questions that were not previously covered: ### Structure work into jobs and steps When a **step** is enough and when a new **job** is warranted. Default to steps within one job for sequential, state-sharing work; reach for a new job only when you need one of: - parallelism for independent work, - an isolated permission boundary (least privilege), - a different runner image or a deployment environment, - a named status check that gates merge, or - a call to a reusable workflow. It also spells out what a job costs — a fresh runner with no shared filesystem, so data crosses a job boundary only through `outputs` or artifacts — so jobs are not created where ordered steps would do. ### Choose an action or a reusable workflow When to package reuse as an **action** (a reusable unit of *steps*, composed inside a job) versus a **reusable workflow** (a reusable unit of *jobs* — a whole multi-job pipeline). Includes the anti-patterns (don't wrap a single task in a reusable workflow; don't force a multi-job pipeline into one action) and cross-links the shared discipline: SHA pinning, start-local-then-promote, and passing secrets explicitly by name. ## Placement Both sections sit together after the security rules and before the authoring mechanics (PowerShell glue, script extraction), giving a top-down flow: security → structure decisions → authoring details. ## Notes - Matches the file's existing voice, cross-reference style, and code-example conventions (SHA-pinned `checkout`, `permissions: {}` default-deny floor, PowerShell steps). - Validated locally: `Test-DocumentationLink.ps1` (all links/anchors resolve), `markdownlint-cli2` with the repo config (0 errors).
1 parent 432d03c commit 81edaa6

1 file changed

Lines changed: 273 additions & 45 deletions

File tree

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

Lines changed: 273 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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
3842
Internal 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
127133
jobs:
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

Comments
 (0)