From b1b0924adc79bbd7fe992b395b92ad8d2d913561 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:55:24 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20add=20job-vs-step?= =?UTF-8?q?=20and=20action-vs-reusable-workflow=20guidance=20to=20the=20Gi?= =?UTF-8?q?tHub=20Actions=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two decision sections to the GitHub Actions coding standard: - "Structure work into jobs and steps" — when a step suffices and when a new job is warranted (parallelism, permission boundary, distinct runner or deployment environment, merge gating, calling a reusable workflow), with the cost of a job (fresh runner, no shared filesystem) called out. - "Choose an action or a reusable workflow" — an action as a reusable unit of steps vs a reusable workflow as a reusable unit of jobs, when to reach for each, and cross-links to SHA pinning, local-then-promote, and explicit secrets passing. --- src/docs/Coding-Standards/GitHub-Actions.md | 144 ++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index f3abb45..9a99ccc 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -147,6 +147,150 @@ as script. - run: echo "${{ github.event.pull_request.title }}" ``` +## Structure work into jobs and steps + +A workflow is a set of **jobs**, and each job is a sequence of **steps**. The two +are not interchangeable: steps in a job share one runner — the same filesystem +and environment, running in order — while every job gets a **fresh runner** and +runs in parallel with its siblings unless told to wait. Reach for a step by +default; add a job only when a step cannot give you what you need. + +- **Default to steps within one job.** Work that is sequential and shares state — + check out, build, then test what you just built — belongs in a single job as + ordered steps. They share the workspace, so each step sees the files the last + one produced without copying anything, and the job reads top to bottom as one + story. +- **Add a job when work is independent and can run in parallel.** Two pieces of + work with no data dependency between them — a lint pass and a security scan — + finish sooner as two jobs on two runners than as serial steps on one. Add + ordering with `needs:` only where a real dependency exists. +- **Add a job to draw a permission boundary.** The job is the unit that + `permissions:` scopes (see + [Grant least-privilege permissions](#grant-least-privilege-permissions)). When + one slice of the work needs a wider scope — a step that comments on the pull + request needs `pull-requests: write` — isolate it in its own job so the rest of + the workflow stays read-only instead of raising the floor for everything. +- **Add a job for a different runner or a deployment environment.** A job pins + its own `runs-on:` and can target an `environment:` with its own protection + rules, approvals, and secrets. Work that must run on a different image, or + behind a manual deploy gate, is a separate job. +- **Add a job when the result must gate merge.** Branch protection requires + status checks by job name (see + [Gate merges with a named status check](#gate-merges-with-a-named-status-check)), + so a result that blocks a pull request is its own job. +- **Add a job to call a reusable workflow.** A reusable workflow is invoked as a + job-level `uses:`, never as a step (see + [Choose an action or a reusable workflow](#choose-an-action-or-a-reusable-workflow)). + +Weigh these against what a job costs. A new job is a fresh runner: it checks out +again, warms its own caches, and shares no filesystem with its siblings — data +crosses a job boundary only through `outputs` or an uploaded artifact. So +splitting sequential, state-sharing work across jobs buys nothing and adds +handoff overhead; parallelism, an isolated permission scope, a distinct +environment, and merge gating are what earn a new job. + +```yaml +permissions: {} + +jobs: + build: + name: Build and test + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + # Sequential, state-sharing work belongs in one job as ordered steps. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Build + shell: pwsh + run: ./build.ps1 # writes artifacts into the workspace + - name: Test + shell: pwsh + run: ./test.ps1 # reads them from the same workspace — no handoff + + report: + name: Report + needs: build # runs only after build succeeds + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write # a wider scope, isolated to this one job + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/publish-summary +``` + +## Choose an action or a reusable workflow + +Both an **action** and a **reusable workflow** package automation for reuse, but +at different granularities, and they plug in at different levels. Match the unit +to the thing being reused. + +- **An action is a reusable unit of *steps*.** It plugs into a job as a step + (`uses:` at step level), runs on the caller's runner inside the caller's job — + sharing that job's filesystem and environment — and does **one well-defined + thing** through an `inputs` / `outputs` interface. Several actions compose + within a single job. This is the smaller unit and the common case; + [Extract non-trivial `run:` scripts into an action](#extract-non-trivial-run-scripts-into-an-action) + is entirely about authoring one. +- **A reusable workflow is a reusable unit of *jobs*.** It is called as a + job-level `uses:` (`org/repo/.github/workflows/build.yml@`) and brings its + own jobs, runners, `permissions`, and `secrets` contract — a whole pipeline, + not a single task. Use it to standardize a **multi-job process** several callers + should run the same way: a shared build-test-publish flow, a common release + pipeline. + +Reach for the smallest unit that fits: + +- **Choose an action** to package a **step-level capability** — one task used + inside a job (`link-check`, `publish-docs`, `start-cloud-agent`). It composes + with the steps around it and hands results back as outputs. +- **Choose a reusable workflow** to package an **end-to-end, multi-job process** — + its own job graph, `needs:` ordering, per-job permissions, and environment + gates — that several repositories should run identically. When the thing worth + sharing is the *whole pipeline* rather than one step of it, the workflow is the + unit that carries the jobs with it. +- **Do not wrap a single task in a reusable workflow.** A workflow drags a job + and a runner behind it; if the reused logic is one step's worth, an action is + lighter, composes with the surrounding steps, and returns outputs to the + calling step directly. Equally, do not force a multi-job pipeline into one + action — an action cannot span jobs or set per-job permissions. + +Both are consumed the same disciplined way: **pinned by full commit SHA** (see +[Pin every action to a full commit SHA](#pin-every-action-to-a-full-commit-sha)), +and both **start local and are promoted to a standalone repository only once a +second consumer appears** (see +[Start local; promote when it is reused](#start-local-promote-when-it-is-reused)). +A reusable workflow additionally takes its secrets **explicitly by name, never +`secrets: inherit`** (see +[Distinguish `vars` from `secrets`](#distinguish-vars-from-secrets)). + +```yaml +permissions: {} + +jobs: + # An action — a step-level capability, composed inside a job. + docs: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/publish-docs # one task; returns outputs + with: + space: ENG + + # A reusable workflow — a whole multi-job process, called as a job. + ci: + uses: org/reusable-workflows/.github/workflows/build.yml@ # vX.Y.Z + permissions: + contents: read + id-token: write + secrets: + PROPAGATION_TOKEN: ${{ secrets.PROPAGATION_TOKEN }} # explicit, by name +``` + ## Default to PowerShell as the glue language Between the declarative steps, a workflow always has some glue to run — reading a From 1ed2cb844f5b48ff878b360edaed744be73f7650 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 16:07:39 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20note=20the=20new?= =?UTF-8?q?=20concurrent-steps=20capability=20in=20the=20GitHub=20Actions?= =?UTF-8?q?=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps can now run concurrently within a single job (the background / wait / wait-all / cancel / parallel keywords, GitHub Actions changelog 2026-06-25). Reflect this in "Structure work into jobs and steps": - Soften the "steps run in order" claim to "in order by default". - Frame a job as the way to spread independent work across separate runners. - Add a "Parallel steps are new and not yet a default" note: the four keywords, the patterns they enable, and guidance to prefer a separate job for ordinary parallelism and confirm toolchain (actionlint/zizmor) support before relying on the feature. --- src/docs/Coding-Standards/GitHub-Actions.md | 59 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 9a99ccc..9233e28 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -151,19 +151,22 @@ as script. A workflow is a set of **jobs**, and each job is a sequence of **steps**. The two are not interchangeable: steps in a job share one runner — the same filesystem -and environment, running in order — while every job gets a **fresh runner** and -runs in parallel with its siblings unless told to wait. Reach for a step by -default; add a job only when a step cannot give you what you need. +and environment, running in order by default — while every job gets a **fresh +runner** and runs in parallel with its siblings unless told to wait. Reach for a +step by default; add a job only when a step cannot give you what you need. - **Default to steps within one job.** Work that is sequential and shares state — check out, build, then test what you just built — belongs in a single job as ordered steps. They share the workspace, so each step sees the files the last one produced without copying anything, and the job reads top to bottom as one story. -- **Add a job when work is independent and can run in parallel.** Two pieces of +- **Add a job to run independent work on its own runner.** Two pieces of work with no data dependency between them — a lint pass and a security scan — - finish sooner as two jobs on two runners than as serial steps on one. Add - ordering with `needs:` only where a real dependency exists. + finish sooner as two jobs on two runners than as serial steps on one, each + isolated with its own environment and permissions. Steps within a single job + can now run concurrently too, but that is newer and shares one runner (see + [Parallel steps are new and not yet a default](#parallel-steps-are-new-and-not-yet-a-default)). + Add ordering with `needs:` only where a real dependency exists. - **Add a job to draw a permission boundary.** The job is the unit that `permissions:` scopes (see [Grant least-privilege permissions](#grant-least-privilege-permissions)). When @@ -186,8 +189,8 @@ Weigh these against what a job costs. A new job is a fresh runner: it checks out again, warms its own caches, and shares no filesystem with its siblings — data crosses a job boundary only through `outputs` or an uploaded artifact. So splitting sequential, state-sharing work across jobs buys nothing and adds -handoff overhead; parallelism, an isolated permission scope, a distinct -environment, and merge gating are what earn a new job. +handoff overhead; parallelism across separate runners, an isolated permission +scope, a distinct environment, and merge gating are what earn a new job. ```yaml permissions: {} @@ -221,6 +224,46 @@ jobs: - uses: ./.github/actions/publish-summary ``` +### Parallel steps are new and not yet a default + +Every step in a job historically ran in sequence — each starting only once the +last finished — and that sequential model is what the rest of this section +assumes. GitHub has since added **concurrent steps** within a single job, through +four workflow keywords: + +- `background: true` starts a step asynchronously and continues straight to the + next step. +- `wait` / `wait-all` block until one, several, or all prior background steps + finish. +- `cancel` stops a background step once it is no longer needed — for instance a + service started only for the steps that run alongside it. +- `parallel` runs a group of steps concurrently and then waits for them: the + convenience form of "start these together, then carry on". + +This covers patterns that used to force a second job or a shell backgrounding +hack (`&`): independent work run at once on one runner, a background service that +later steps use and then shut down, or non-blocking work — uploading telemetry +while packaging continues — overlapping the steps after it. Because the steps +share the runner, they also share its filesystem, which a separate job does not. + +Adopt it deliberately: + +- **Prefer a separate job for ordinary parallelism.** When independent work does + not need a shared workspace, two jobs stay the clearer, better-isolated choice — + separate runners, permissions, and logs. Reach for concurrent steps only when + the work genuinely benefits from one shared runner. +- **Confirm the toolchain supports the keywords first.** The feature is recent, + so the pinned [`actionlint` / `zizmor`](#toolchain) versions and the runner + image in use may not yet validate or run `background` / `wait` / `parallel`; + verify before relying on it. Expect interleaved concurrent steps to be harder + to follow, so keep the [grouped-logging discipline](#build-in-logging-and-diagnostics). + +Until it has settled in the ecosystem, treat concurrent steps as a tool for the +few cases that need a shared runner, and let a separate job remain the default +answer to "these should run in parallel". See the +[workflow syntax reference](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions) +for exact usage. + ## Choose an action or a reusable workflow Both an **action** and a **reusable workflow** package automation for reuse, but From d9c095f6a2f2cce9fa04dd9700aa830777cf650c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 16:20:26 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20split=20the=20secr?= =?UTF-8?q?ets=20correct/avoid=20example=20so=20each=20snippet=20is=20vali?= =?UTF-8?q?d=20YAML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Distinguish vars from secrets" example packed a Correct and an Avoid snippet into one fenced block, repeating the top-level `jobs:` key — which is not a valid YAML document (duplicate mapping key, and rejected by GitHub Actions / yamllint). Split it into two fenced blocks so the snippet parses on its own. Validated every fenced example in the standard: all 13 YAML blocks parse under a strict duplicate-key-aware loader, and both bash blocks pass `bash -n`. The one remaining non-code block is an illustrative directory tree (text). --- src/docs/Coding-Standards/GitHub-Actions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 9233e28..23ac0a4 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -122,7 +122,9 @@ jobs: uses: org/reusable-workflows/.github/workflows/build.yml@ # vX.Y.Z secrets: PROPAGATION_TOKEN: ${{ secrets.PROPAGATION_TOKEN }} +``` +```yaml # Avoid — forwards every secret; the call site no longer documents what's used jobs: call: From 2af1d6ea856f023d6fccd6c628d705431012479c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 17:38:01 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20name=20the=20jobs?= =?UTF-8?q?=20in=20the=20action/reusable-workflow=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard's own "Pin the runner and name everything" rule asks for a `name:` on every job. The action/reusable-workflow example's `docs` and `ci` jobs were unnamed — unlike every other example that shows a job with steps (the job/step, logging, and status-check examples all name their jobs). Add `name: Publish docs` and `name: CI` so the example follows the standard it documents. Audited every fenced example against the standard: SHA-pinned actions, pinned runners (ubuntu-24.04), least-privilege permissions, and secrets-by-name all hold in the Correct/neutral snippets; the only unpinned action, `secrets: inherit`, and inline-untrusted-input appear solely in the labelled Avoid snippets. --- src/docs/Coding-Standards/GitHub-Actions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 23ac0a4..be299d8 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -317,6 +317,7 @@ permissions: {} jobs: # An action — a step-level capability, composed inside a job. docs: + name: Publish docs runs-on: ubuntu-24.04 permissions: contents: read @@ -328,6 +329,7 @@ jobs: # A reusable workflow — a whole multi-job process, called as a job. ci: + name: CI uses: org/reusable-workflows/.github/workflows/build.yml@ # vX.Y.Z permissions: contents: read From cabb78acc5ef9b14fcf426651d165ef07066065b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 18:14:54 +0200 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20require=20a=20name?= =?UTF-8?q?=20on=20every=20step,=20and=20single-blank-line=20spacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthen the naming rule from "every job and every non-trivial step" to every job AND every step, with the rationale: the name in the code is the label shown in the portal (traceability / code-portal parity); it is a stable handle that does not drift when the underlying `run`/`uses` changes; it names a failure by intent rather than by a decoded command line; and — because we pin actions to full SHAs — an unnamed action step would otherwise wear its 40-character SHA as its UI label. Add a companion rule: separate each job and each step with a single blank line. Apply both across every example that contains steps (pinning, untrusted-input, job/step, action/reusable-workflow, logging report, status check). Verified: all 15 code examples still parse, every step now starts with `- name:`, markdownlint clean, links resolve. --- src/docs/Coding-Standards/GitHub-Actions.md | 69 ++++++++++++++++----- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index be299d8..947c714 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -28,11 +28,15 @@ them to point at different code. A full commit SHA is **immutable**. ```yaml # Correct — immutable SHA; comment carries the readable version -- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 +- name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + +- name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # Avoid — mutable tag; the referenced code can change under us -- uses: actions/checkout@v6 +- name: Check out the repository + uses: actions/checkout@v6 ``` Internal actions follow the same rule. @@ -141,12 +145,14 @@ as script. ```yaml # Correct — value arrives as an environment variable, not inlined into the shell -- env: +- name: Echo the PR title + env: TITLE: ${{ github.event.pull_request.title }} run: echo "$TITLE" # Avoid — attacker-controlled title is executed as shell -- run: echo "${{ github.event.pull_request.title }}" +- name: Echo the PR title + run: echo "${{ github.event.pull_request.title }}" ``` ## Structure work into jobs and steps @@ -205,10 +211,13 @@ jobs: contents: read steps: # Sequential, state-sharing work belongs in one job as ordered steps. - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Build shell: pwsh run: ./build.ps1 # writes artifacts into the workspace + - name: Test shell: pwsh run: ./test.ps1 # reads them from the same workspace — no handoff @@ -222,8 +231,11 @@ jobs: contents: read pull-requests: write # a wider scope, isolated to this one job steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/publish-summary + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Publish the summary comment + uses: ./.github/actions/publish-summary ``` ### Parallel steps are new and not yet a default @@ -322,8 +334,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/publish-docs # one task; returns outputs + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Publish the docs + uses: ./.github/actions/publish-docs # one task; returns outputs with: space: ENG @@ -552,13 +567,27 @@ concurrency: cancel-in-progress: false ``` -## Pin the runner and name everything +## Pin the runner, name everything, and space it out - **Pin `runs-on` to a specific runner image** (`ubuntu-24.04`) rather than `ubuntu-latest`, so a runner upgrade is a deliberate, reviewable change rather than a silent one. -- **Give every job and every non-trivial step a `name:`.** Named steps make the - Actions UI and logs readable and make failures easy to locate. +- **Give every job and every step a `name:`** — not only the non-trivial ones. + Left unnamed, a step is labelled by whatever GitHub derives from its `run` or + `uses`: the full pinned SHA for an action (`Run actions/checkout@de0fac…`), or + the truncated first line of a script — both hard to read, and both changing + whenever the command does. A written `name:` is the exact text shown in the + Actions UI, the logs, and the Checks view, so a step **reads the same in the + code as in the portal**, stays a **stable handle** for links and log searches + when the command underneath it changes, and names a failure by intent rather + than by a decoded command line. Under the + [SHA-pinning rule](#pin-every-action-to-a-full-commit-sha) it matters all the + more: an unnamed action step wears its 40-character SHA as its label. +- **Separate each job and each step with a single blank line.** One blank line + between consecutive steps, and one between consecutive jobs, makes every unit a + self-contained block that is easy to scan, reorder, and read in a diff. Use + exactly one — with none, adjacent steps blur together; with several, the file + turns gappy. ## Build in logging and diagnostics @@ -713,8 +742,11 @@ jobs: pull-requests: write # comment on the PR steps: # A local action runs from the checked-out repo, so check out first. - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/publish-summary # upserts one marked comment + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Publish the summary comment + uses: ./.github/actions/publish-summary # upserts one marked comment ``` ### Gate merges with a named status check @@ -741,6 +773,9 @@ jobs: contents: read steps: # A local action runs from the checked-out repo, so check out first. - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/validate-publishable # exits 1 on a blocker + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Validate that the docs are publishable + uses: ./.github/actions/validate-publishable # exits 1 on a blocker ``` From c44d960c1b558ea7ecf30c3dfaaea71dcc882f5a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 18:19:00 +0200 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20clarify=20local=20?= =?UTF-8?q?vs=20standalone=20action/workflow=20pinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Choose an action or a reusable workflow' summary said both are 'consumed the same disciplined way: pinned by full commit SHA', which over-generalised: a local action or workflow referenced by path (./.github/...) runs at the checked-out commit and is not SHA-pinned — only contradicting the local-action examples. Reframe as a lifecycle: start local (by path), promote to a standalone repo once a second consumer appears, and only then consume it by full commit SHA like any third-party dependency. --- src/docs/Coding-Standards/GitHub-Actions.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 947c714..a228738 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -314,11 +314,13 @@ Reach for the smallest unit that fits: calling step directly. Equally, do not force a multi-job pipeline into one action — an action cannot span jobs or set per-job permissions. -Both are consumed the same disciplined way: **pinned by full commit SHA** (see -[Pin every action to a full commit SHA](#pin-every-action-to-a-full-commit-sha)), -and both **start local and are promoted to a standalone repository only once a -second consumer appears** (see -[Start local; promote when it is reused](#start-local-promote-when-it-is-reused)). +Both follow the same lifecycle: **start as a local action or workflow**, +referenced by path (`./.github/...`) so it runs at the checked-out commit, and +**promote it to a standalone repository only once a second consumer appears** +(see [Start local; promote when it is reused](#start-local-promote-when-it-is-reused)). +Once standalone — like any third-party dependency — it is **consumed by full +commit SHA** (see +[Pin every action to a full commit SHA](#pin-every-action-to-a-full-commit-sha)). A reusable workflow additionally takes its secrets **explicitly by name, never `secrets: inherit`** (see [Distinguish `vars` from `secrets`](#distinguish-vars-from-secrets)). From 19c5741821a4d2f3fb48ced80d92fb92fe730088 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 18:23:18 +0200 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20fix=20keyword-coun?= =?UTF-8?q?t=20consistency=20in=20the=20parallel-steps=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the intro said 'four workflow keywords' but the list names five distinct keys (background, wait, wait-all, cancel, parallel), and the toolchain caveat named only a three-key subset. Drop the count ('new workflow keywords') and make the caveat refer to 'the new keywords' so it covers all of them. --- src/docs/Coding-Standards/GitHub-Actions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index a228738..1b67009 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -243,7 +243,7 @@ jobs: Every step in a job historically ran in sequence — each starting only once the last finished — and that sequential model is what the rest of this section assumes. GitHub has since added **concurrent steps** within a single job, through -four workflow keywords: +new workflow keywords: - `background: true` starts a step asynchronously and continues straight to the next step. @@ -268,8 +268,8 @@ Adopt it deliberately: the work genuinely benefits from one shared runner. - **Confirm the toolchain supports the keywords first.** The feature is recent, so the pinned [`actionlint` / `zizmor`](#toolchain) versions and the runner - image in use may not yet validate or run `background` / `wait` / `parallel`; - verify before relying on it. Expect interleaved concurrent steps to be harder + image in use may not yet validate or run the new keywords; verify before + relying on it. Expect interleaved concurrent steps to be harder to follow, so keep the [grouped-logging discipline](#build-in-logging-and-diagnostics). Until it has settled in the ecosystem, treat concurrent steps as a tool for the From 37e6f6b06681f85cf0a78a45fa498ebf84bf8f8d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 18:50:41 +0200 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20convert=20the=20lo?= =?UTF-8?q?gging=20examples=20from=20bash=20echo=20to=20PowerShell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerShell is the standard's default glue language, but the three examples in "Build in logging and diagnostics" used bash `echo` — contradicting that rule, and each one also contradicted its own bullet (which advocates the PowerShell helpers). Convert them: - grouped logging -> `LogGroup 'phase' { ... }` - annotations -> `Write-GitHubNotice` / `Write-GitHubError` (which escape dynamic data for you, so the manual %25/%0D/%0A note stays in prose but drops from code) - step summary -> `Set-GitHubStepSummary` with the PSModule `Markdown` module (`Heading`, `Table`, `Details`) Verified helper signatures against the installed PSModule `GitHub` module (v0.43.1) and the `Markdown` module's `Heading`/`Table`/`Details` aliases; both PowerShell blocks pass the PowerShell language parser, and no `echo`/`shell: bash` examples remain. --- src/docs/Coding-Standards/GitHub-Actions.md | 60 ++++++++++----------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 1b67009..7d9c3da 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -624,19 +624,19 @@ way until someone wants it. ```yaml - name: Publish - shell: bash + shell: pwsh env: SPACE: ${{ inputs.space }} DRY_RUN: ${{ inputs.dry-run }} run: | - echo "::group::Resolve inputs" - echo "space = ${SPACE}" - echo "dry-run = ${DRY_RUN}" - echo "::endgroup::" - - echo "::group::Publish" - # ...every step of the actual work, logged here as it happens... - echo "::endgroup::" + LogGroup 'Resolve inputs' { + Write-Host "space = $env:SPACE" + Write-Host "dry-run = $env:DRY_RUN" + } + + LogGroup 'Publish 42 pages' { + # ...each page logged here as it is published... + } ``` ### Call out the result with an annotation @@ -658,13 +658,13 @@ way until someone wants it. helper that emits the command handles this for you (PowerShell: `Write-GitHubNotice` / `Write-GitHubError`). -```bash -# Integer counts are safe to interpolate. Escape free-form text (names, messages) -# with %25 / %0D / %0A first, or emit it through a helper such as Write-GitHubNotice. -echo "::notice title=Published::created ${CREATED}, updated ${UPDATED}" +```powershell +# The GitHub module escapes dynamic data for you — no manual %25 / %0D / %0A. +Write-GitHubNotice -Title 'Published' -Message "created $($created.Count), updated $($updated.Count)" -# A blocking failure: annotate, then exit non-zero (see the status check below). -echo "::error title=Publish failed::${FAILED} page(s) rejected" +# A blocking failure: annotate, then throw so the step exits non-zero (see the status check below). +Write-GitHubError -Title 'Publish failed' -Message "$($rejected.Count) page(s) rejected" +throw "$($rejected.Count) page(s) rejected" ``` ### Report the bigger picture in a step summary @@ -687,22 +687,20 @@ echo "::error title=Publish failed::${FAILED} page(s) rejected" [Generalize the action](#generalize-the-action-drive-behaviour-through-inputs-and-outputs)), so a calling workflow acts on a value instead of scraping the summary. -```bash -{ - echo "## ✅ Documentation publish" - echo "" - echo "| Result | Count |" - echo "| ------- | ----- |" - echo "| Created | ${CREATED} |" - echo "| Updated | ${UPDATED} |" - echo "| Skipped | ${SKIPPED} |" - echo "" - echo "
Pages created (${CREATED})" - echo "" - echo "${CREATED_LIST}" # one entry per line — kept closed until opened - echo "" - echo "
" -} >> "$GITHUB_STEP_SUMMARY" +```powershell +Set-GitHubStepSummary -Summary ( + Heading 2 '✅ Documentation publish' { + Table { + [pscustomobject]@{ Result = 'Created'; Count = $created.Count } + [pscustomobject]@{ Result = 'Updated'; Count = $updated.Count } + [pscustomobject]@{ Result = 'Skipped'; Count = $skipped.Count } + } + + Details "Pages created ($($created.Count))" { + $created -join "`n" # one title per line — kept closed until opened + } + } +) ``` ### Report back on the triggering PR or issue From dac4408e5a1e4c3447dea70513b43394fee07223 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 18:55:55 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20convert=20the=20un?= =?UTF-8?q?trusted-input=20example=20to=20PowerShell=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the "Never expand untrusted input inline" example still used bash (`echo`, `$TITLE`) with no `shell:`, so a reader pasting it into a pwsh step would find `$TITLE` empty. Set `shell: pwsh` and use `Write-Host $env:TITLE` (safe, the value arrives as an env var) versus an inline expansion of the untrusted title (unsafe). Now every example step declares `shell: pwsh`; the only remaining "echo" is the prose "never echo a secret". --- src/docs/Coding-Standards/GitHub-Actions.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 7d9c3da..1b331f4 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -145,14 +145,16 @@ as script. ```yaml # Correct — value arrives as an environment variable, not inlined into the shell -- name: Echo the PR title +- name: Print the PR title + shell: pwsh env: TITLE: ${{ github.event.pull_request.title }} - run: echo "$TITLE" + run: Write-Host $env:TITLE -# Avoid — attacker-controlled title is executed as shell -- name: Echo the PR title - run: echo "${{ github.event.pull_request.title }}" +# Avoid — attacker-controlled title is spliced into the script and executed +- name: Print the PR title + shell: pwsh + run: Write-Host "${{ github.event.pull_request.title }}" ``` ## Structure work into jobs and steps