Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/shared-checks.yml:
ignore:
# Callers in other repositories must reach this repository's action at the commit they pinned, which only the $/ self-repository syntax does; actionlint predates it.
- specifying action "\$/
40 changes: 40 additions & 0 deletions .github/actions/workflow-checks/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Workflow checks

description: Runs actionlint, zizmor and the workflow convention checker over the repository's workflows and composite actions.

inputs:
files:
description: Files for the convention checker, as shell patterns; patterns that match nothing are skipped.
default: .github/workflows/*.yml .github/workflows/*.yaml .github/actions/*/action.yml .github/actions/*/action.yaml

github-token:
description: Token for zizmor's online audits; read access to the repository is enough.
required: true

runs:
using: composite
steps:
- name: Run actionlint
shell: bash
env:
# renovate: datasource=docker depName=rhysd/actionlint
ACTIONLINT_IMAGE: rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667
run: docker run --rm --volume "$GITHUB_WORKSPACE:/repo:ro" --workdir /repo "$ACTIONLINT_IMAGE" -color

- name: Run zizmor
if: "!cancelled()"
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
# renovate: datasource=docker depName=ghcr.io/zizmorcore/zizmor
ZIZMOR_IMAGE: ghcr.io/zizmorcore/zizmor:1.31.1@sha256:42d409dbb851d4c5d35a058cf042b828bea1e3f2884378c526eb0974780e18d9
run: docker run --rm --env GH_TOKEN --volume "$GITHUB_WORKSPACE:/repo:ro" --volume "$GITHUB_ACTION_PATH:/config:ro" --workdir /repo "$ZIZMOR_IMAGE" --config /config/zizmor.yml --format github .

- name: Check the workflow convention
if: "!cancelled()"
shell: bash
env:
FILES: ${{ inputs.files }}
run: |
shopt -s nullglob
node "$GITHUB_ACTION_PATH/../../../scripts/check-workflows.mjs" $FILES
9 changes: 9 additions & 0 deletions .github/actions/workflow-checks/zizmor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
rules:
unpinned-uses:
config:
policies:
"*": hash-pin

# Local actions stay on ./ until actionlint understands the $/ self-repository syntax.
self-repository:
disable: true
10 changes: 10 additions & 0 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,15 @@
"extends": [
"github>BSStudio/renovate-config",
"github>BSStudio/renovate-config:monthly"
],
"customManagers": [
{
"customType": "regex",
"description": "Container images that composite actions run with docker run stay pinned by tag and digest; this keeps both current.",
"managerFilePatterns": ["/^\\.github/actions/.+/action\\.yml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)\\s+\\w+: \\S+?:(?<currentValue>[^@\\s]+)@(?<currentDigest>sha256:[a-f0-9]{64})"
]
}
]
}
18 changes: 18 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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: ./.github/workflows/shared-checks.yml
47 changes: 47 additions & 0 deletions .github/workflows/shared-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Shared checks

on:
workflow_call:
inputs:
dependency-review:
description: Review dependency changes for licences and known vulnerabilities. Public repositories only.
type: boolean
default: false

permissions:
contents: read

jobs:
workflows:
name: Workflows
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Check workflows
uses: $/.github/actions/workflow-checks
with:
github-token: ${{ github.token }}

dependencies:
name: Dependencies
if: inputs.dependency-review && github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Review dependency changes
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
# Every dependency's licence may be combined with GPL-3.0 code that is distributed (public Docker images).
# Missing on purpose, so they fail and need a decision: AGPL-3.0, proprietary and source-available
# licences, non-commercial ones, and GPL-incompatible copyleft (EPL, CDDL, GPL-2.0-only).
allow-licenses: >-
MIT, MIT-0, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, Zlib, BSL-1.0, Unlicense, CC0-1.0,
WTFPL, BlueOak-1.0.0, Python-2.0, PSF-2.0, Artistic-2.0, Unicode-3.0, Unicode-DFS-2016,
MPL-2.0, LGPL-2.1-only, LGPL-2.1-or-later, LGPL-3.0-only, LGPL-3.0-or-later,
GPL-2.0-or-later, GPL-3.0-only, GPL-3.0-or-later,
CC-BY-3.0, CC-BY-4.0, OFL-1.1
14 changes: 12 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,29 @@ on:
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

defaults:
run:
shell: bash

jobs:
test:
name: Test scripts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24

- name: Run tests
run: node --test 'scripts/*.test.mjs'
run: node --test "scripts/*.test.mjs"
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ node scripts/apply-settings.mjs --dry-run BSStudio/backstage # one repo
A named repo must be listed in `settings/repos.json`. A run writes only what differs, then reads the repo again and reports anything GitHub did not take. Run it as an admin of the listed repos: without admin rights GitHub leaves some settings out of its answers (they show as `missing`) and refuses the writes. A failed call is reported with the `gh` command that failed.

Security features (secret scanning, push protection, Dependabot alerts, CodeQL default setup, private vulnerability reporting) come from the organisation's "GitHub recommended" code security configuration, which is the default for new public repos. The script checks that it is attached and never attaches it.

## Workflows

Every workflow follows one convention, written down in [`docs/workflows.md`](docs/workflows.md). Each repo enforces it with a `.github/workflows/checks.yml` that calls `.github/workflows/shared-checks.yml` here (the copyable caller is in the convention): `Checks / Workflows` runs actionlint, zizmor and `scripts/check-workflows.mjs` over the repo's workflows and composite actions, and `Checks / Dependencies` reviews new dependencies for vulnerabilities and licences in repos that turn it on. Wherever a ruleset exists it requires `Checks / Workflows`, plus `Checks / Dependencies` where the review is on.

Callers pin a tagged release of this repo; Renovate proposes each new release. The checks run from the same commit as the workflow that calls them: `shared-checks.yml` reaches its composite action through the `$/` self-repository syntax.
87 changes: 87 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Workflow convention

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. The few rules that need judgement are kept in review and marked as such.

## Files

- Workflows end in `.yml` and start without a `---` document marker.
- Top-level keys come in this order: `name`, `on`, `permissions`, `concurrency`, `defaults`, `env`, `jobs`.
- `on` is 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.

## Permissions and secrets

- A workflow grants no more than reading by default: `permissions` is always set at the top, normally to `contents: read`. Write scopes go on the one job that needs them.
- A job that runs on pull requests holds a write scope only if every step that uses it is skipped on pull requests. Today this covers the single build-and-push jobs of the publish workflows, until they move to a shared publish workflow that splits building from pushing.
- Secrets are passed only on events other than `pull_request` (review).
- Checkouts set `persist-credentials: false`; a job that pushes keeps the credentials and says why in a comment.
- Every action is pinned by its full commit SHA with the release in a comment, `@<sha> # vX.Y.Z`. Renovate keeps the pins current.

## Concurrency and shell

- A new push to a pull request cancels the run it replaces: every workflow that runs on `pull_request` sets `concurrency` with `group: ${{ github.workflow }}-${{ github.ref }}` and `cancel-in-progress: ${{ github.event_name == 'pull_request' }}`.
- A failing command fails its step, including inside a pipe: every workflow with a `run` step sets `defaults.run.shell: bash`. Naming bash explicitly turns on `pipefail`; the implicit default shell does not.
- `working-directory` lives in `defaults.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 as `with: working-directory` are not affected.

## Jobs

- Every job has a `name` and a `timeout-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 `env` once.

## Steps

- Every step has a sentence-case `name` as its first key, and `run` comes 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).
- `if` conditions are written without `${{ }}`, and use `!cancelled()` instead of `always()`.
- No `${{ }}` inside `run`: values reach the shell through `env`, so nothing from the event payload is spliced into a script.

## Spacing and quoting

- Exactly one empty line separates top-level keys, jobs and steps; there is none right before `steps`.
- Strings are quoted only where YAML requires it, with double quotes.

## Composite actions

The same rules apply to `action.yml` files, except that GitHub requires `shell` on every `run` step there.

## Calling the shared checks

Every repository runs the checks through the reusable workflow in this repository, from a `.github/workflows/checks.yml` like this:

```yaml
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 as `Checks / Workflows` and `Checks / Dependencies`, the names the repository's ruleset requires.
- `dependency-review: true` adds 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 in `shared-checks.yml`. Without the input the `Dependencies` job 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.

## Running the checks locally

From a repository's root, with actionlint and zizmor installed and this repository cloned next to it:

```bash
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
```
26 changes: 26 additions & 0 deletions scripts/check-workflows.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { checkWorkflow } from "./workflow-style.mjs";

const files = process.argv.slice(2);
if (files.length === 0) {
console.error("usage: node scripts/check-workflows.mjs <workflow or action file>...");
process.exit(2);
}

const annotate = process.env.GITHUB_ACTIONS === "true";
let status = 0;
for (const file of files) {
let text;
try {
text = readFileSync(file, "utf8");
} catch (error) {
console.error(`${file}: ${error.message}`);
status = 2;
continue;
}
for (const { path, line, message } of checkWorkflow(text, file)) {
console.log(annotate ? `::error file=${path},line=${line}::${message}` : `${path}:${line}: ${message}`);
status ||= 1;
}
}
process.exitCode = status;
57 changes: 57 additions & 0 deletions scripts/check-workflows.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, test } from "node:test";
import { fileURLToPath } from "node:url";

const cli = fileURLToPath(new URL("./check-workflows.mjs", import.meta.url));
const dir = mkdtempSync(join(tmpdir(), "check-workflows-"));
after(() => rmSync(dir, { recursive: true, force: true }));
const write = (name, text) => {
const file = join(dir, name);
writeFileSync(file, text);
return file;
};
const run = (files, env = {}) => {
const { GITHUB_ACTIONS, ...rest } = process.env;
return spawnSync(process.execPath, [cli, ...files], { encoding: "utf8", env: { ...rest, ...env } });
};

const clean = write(
"clean.yml",
["name: CI", "", "on:", " push:", "", "permissions:", " contents: read", "", "jobs:", " test:", " name: Test", " runs-on: ubuntu-latest", " timeout-minutes: 5", " steps:", " - name: Check out", " uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", ""].join("\n"),
);
const broken = write("broken.yml", "---\nname: CI\n");

test("a clean file prints nothing and exits 0", () => {
const result = run([clean]);
assert.equal(result.stdout, "");
assert.equal(result.status, 0);
});

test("findings print as path:line: message and exit 1", () => {
const result = run([broken]);
assert.equal(result.status, 1);
assert.match(result.stdout, new RegExp(`^${broken.replaceAll("\\", "\\\\")}:1: document start marker$`, "m"));
});

test("inside GitHub Actions findings become error annotations", () => {
const result = run([broken], { GITHUB_ACTIONS: "true" });
assert.equal(result.status, 1);
assert.match(result.stdout, /^::error file=.*broken\.yml,line=1::document start marker$/m);
});

test("an unreadable file exits 2 after checking the rest", () => {
const result = run([join(dir, "missing.yml"), broken]);
assert.equal(result.status, 2);
assert.match(result.stderr, /missing\.yml/);
assert.match(result.stdout, /document start marker/);
});

test("no files is a usage error", () => {
const result = run([]);
assert.equal(result.status, 2);
assert.match(result.stderr, /^usage: /);
});
Loading
Loading