Every GitHub Actions workflow and composite action in the studio's repositories follows these rules, so a workflow in one repository reads like one in any other and the same mistakes are caught everywhere. The Checks / Workflows check enforces them on every pull request: actionlint for syntax and shell scripts, zizmor for security, and scripts/check-workflows.mjs for the rest. Rules that no tool checks are kept in review and marked as such.
- Workflows end in
.ymland start without a---document marker. - Top-level keys come in this order:
name,on,permissions,concurrency,defaults,env,jobs. onis a map. A flow list holds at most one item (branches: [main]); two or more items go in a block list.- The files stay inside the plain subset of YAML the checker reads: block style with 2-space indentation, no anchors or aliases, no flow maps. The checker reports anchors, aliases and flow maps; indentation and values continued on the next line are kept in review.
- A workflow grants no more than reading by default:
permissionsis always set at the top, normally tocontents: read. - Write scopes go on the one job that needs them (review).
- No code from a pull request runs with a write scope (review). A job that runs on pull requests holds one only if every step that uses it is skipped on pull requests, or if the job checks nothing out and its steps are pinned actions that act on the pull request itself. The first case covers today's single build-and-push jobs of the publish workflows, until they move to a shared publish workflow that splits building from pushing; the second covers labeling pull requests.
- Secrets are passed only on events other than
pull_request, except upload tokens that can only add reports, such as Codecov's (review). - Checkouts set
persist-credentials: false; a job that pushes keeps the credentials and says why in a comment (the comment: review). - Every action is pinned by its full commit SHA with the release in a comment,
@<sha> # vX.Y.Z(the comment: review). Renovate keeps the pins current.
- A new push to a pull request cancels the run it replaces: every workflow that runs on
pull_requestsetsconcurrencywithgroup: ${{ github.workflow }}-${{ github.ref }}andcancel-in-progress: ${{ github.event_name == 'pull_request' }}(the values: review). - A failing command fails its step, including inside a pipe: every workflow with a
runstep setsdefaults.run.shell: bash. Naming bash explicitly turns onpipefail; the implicit default shell does not. - Steps do not set their own
shell; a step that needs another shell starts it from bash, as inpwsh -File script.ps1. working-directorylives indefaults.run: at workflow level when every job shares it, otherwise per job. A step sets its own only when it alone differs. Action inputs such aswith: working-directoryare not affected (review).
- Every job has a
nameand atimeout-minutes, about two to three times its usual duration. A job that calls a reusable workflow cannot set a timeout and is exempt. - A job that is a required status check keeps its name, because renaming it leaves pull requests waiting for a check that never reports (review).
- A value several steps use goes in the job's
envonce (review).
- Every step has a
nameas its first key, in sentence case (review), andruncomes last. - A step runs one command that lives in the repository: a package script,
uv run …, or a script file. Shell longer than a few lines moves into a script, so local runs and CI run the same thing and shellcheck sees it (review). ifconditions are written without${{ }}, and use!cancelled()instead ofalways().- No
${{ }}insiderun: values reach the shell throughenv, so nothing from the event payload is spliced into a script.
- Exactly one empty line separates top-level keys, jobs and steps; there is none right before
steps. A job withservicesputs them right beforesteps, with one empty line before and after them. - Plain strings go unquoted; patterns such as globs and cron expressions, and strings YAML requires quoted, use double quotes. The checker reports single quotes; which strings need quotes is kept in review.
The same rules apply to action.yml files, except that GitHub requires shell on every run step there. actionlint does not read action.yml files, so scripts in composite actions get no shellcheck: they stay at one command or move into a script file (review).
Every repository runs the checks through the reusable workflow in this repository, from a .github/workflows/checks.yml like this:
name: Checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
checks:
name: Checks
uses: BSStudio/.github/.github/workflows/shared-checks.yml@<release commit SHA> # vX.Y.Z
with:
dependency-review: true- The job must be named
Checks: the checks then report asChecks / WorkflowsandChecks / Dependencies, the names the repository's ruleset requires. dependency-review: trueadds the dependency review for repositories with package manifests. It works on public repositories only and checks every new or changed dependency for known vulnerabilities and for a licence outside the allow-list inshared-checks.yml. Without the input theDependenciesjob is skipped, which still satisfies a required check.- The pin follows this repository's releases: Renovate proposes each new release in every caller, and that pull request runs the new checks against the repository before anything merges.
From a repository's root, with actionlint and zizmor installed and this repository cloned next to it:
actionlint
GH_TOKEN=$(gh auth token) zizmor --config ../.github/.github/actions/workflow-checks/zizmor.yml .
node ../.github/scripts/check-workflows.mjs .github/workflows/*.yml .github/actions/*/action.yml