ci(workflows): name workflows for GitHub's lists

Every scheduled workflow opened with an alarm clock, so the Actions list
read as a column of identical icons that said nothing about the run. The
clock now lives in run-name behind github.event_name == 'schedule', where
it marks the one thing it can mark: a run nobody triggered. The name says
what the run does, in two or three words.

The dispatch menu shows names in one narrow column and truncates them, so
a new lint caps a workflow_dispatch name at 25 characters and points the
longer, explanatory wording at run-name, which carries no such limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-22 11:18:54 +02:00
1 parent 7b5c462af2
commit e7fadf02fe
15 files changed
+141 -27

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
name: "🏃 Test: Runner smoke (self-hosted)"
name: "🏃 Test: Runner smoke"
run-name: "🏃 Test: Runner smoke (self-hosted)"
on:
workflow_dispatch:
+2 -1
View File
@@ -1,4 +1,5 @@
name: "⏰ Cleanup: Stale Repository Data"
name: "🧹 Prune stale data"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🧹 Prune stale data"
on:
schedule:
+2 -1
View File
@@ -1,4 +1,5 @@
name: "⏰ Images: Cleanup CI (GHCR)"
name: "🧹 Prune CI images"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🧹 Prune CI images"
on:
schedule:
+2 -1
View File
@@ -1,4 +1,5 @@
name: "⏰ Mirror: Docker Hub → GHCR"
name: "🪞 Mirror all images"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🪞 Mirror all images"
on:
schedule:
+2 -1
View File
@@ -1,4 +1,5 @@
name: "⏰ Release: Highest Missing Version"
name: "🚀 Release missing tag"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🚀 Release missing tag"
on:
schedule:
+2 -1
View File
@@ -11,7 +11,8 @@
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "⏰ Scan: CodeQL (Advanced)"
name: "🔐 Scan with CodeQL"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🔐 Scan with CodeQL"
concurrency:
group: codeql-${{ github.repository }}-${{ github.ref }}
@@ -3,7 +3,8 @@
# policy, and support documentation.
# nocheck: comments-valid vendored OSSF scorecard workflow; upstream comments kept verbatim
name: "⏰ Scorecard supply-chain security"
name: "🛡️ Score supply chain"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🛡️ Score supply chain"
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
+2 -1
View File
@@ -1,4 +1,5 @@
name: "⏰ Update: Versions"
name: "🔄 Update versions"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🔄 Update versions"
on:
push:
@@ -1,4 +1,5 @@
name: "🧹 Images: Cleanup GHCR (Private)"
name: "🧹 Images: Cleanup GHCR"
run-name: "🧹 Images: Cleanup GHCR (Private)"
on:
workflow_dispatch:
@@ -6,12 +6,13 @@ For the script placement rule that applies to extracted shell helpers, see [scri
## Naming 🏷️
Every workflow MUST follow the schema `"[Emoji] Category: Subject (Qualifier)"`.
Every workflow MUST follow the schema `"[Emoji] Category: Subject (Qualifier)"`, except a scheduled one — see [Scheduled runs](#scheduled-runs-).
- You MUST quote the `name:` value in double quotes because the colon (`:`) in the name is a reserved YAML character.
- You MUST place the emoji before the category, never after.
- You MUST NOT add emojis to `docs/agents/` files, but workflow `name:` fields are not agent files and MUST use emojis.
- The qualifier in parentheses is OPTIONAL. Use it only when two workflows share the same category and subject.
- A workflow with a `workflow_dispatch` trigger MUST keep its `name:` at or below 25 characters, enforced by [test_workflow_dispatch_name_length.py](../../../../../tests/lint/repository/test_workflow_dispatch_name_length.py). The dispatch menu lists those names in one narrow column, where a longer one is truncated. Move the explanatory wording into `run-name:`, which carries no such limit.
### Emoji legend 📋
@@ -41,6 +42,19 @@ name: "🪞 Mirror: Docker Hub → GHCR (only missing)"
name: "🚫 Cancel: PR Runs on Close"
```
### Scheduled runs ⏰
A scheduled workflow (`cron-*.yml`) drops the `Category:` schema: its `name:` MUST read as one short sentence saying what the run does, because the Actions list shows scheduled runs next to each other with no PR or branch to tell them apart.
It MUST NOT carry ⏰ in its `name:`, and MUST carry a `run-name:` that prefixes ⏰ only when the schedule started the run, so a dispatched run is not mislabelled as scheduled:
```yaml
name: "🔄 Update versions"
run-name: "${{ github.event_name == 'schedule' && '⏰ ' || '' }}🔄 Update versions"
```
Enforced by [test_workflow_trigger_prefix.py](../../../../../tests/lint/repository/test_workflow_trigger_prefix.py), which also owns the `entry-` / `call-` / `cron-` file-name rule.
## Shell execution 📜
- Multi-line shell logic in workflow `run:` blocks MUST be extracted into dedicated `.sh` files under `scripts/`.
@@ -41,7 +41,7 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched
| Workflow | Description | Trigger | Inputs |
|---|---|---|---|
| [cron-security-codeql.yml](../../../../../.github/workflows/cron-security-codeql.yml): `⏰ Scan: CodeQL (Advanced)` | CodeQL static analysis. Invoked by [call-orchestrator.yml](../../../../../.github/workflows/call-orchestrator.yml) so every CI run produces a single scan; additionally runs on a weekly cron so coverage does not drop when no pushes land on `main`. See [pipeline.md](../../../artefact/git/pipeline.md) for the gating behaviour. | reusable, auto (`schedule`: weekly Mon 00:00 UTC) | none |
| [cron-security-codeql.yml](../../../../../.github/workflows/cron-security-codeql.yml): `🔐 Scan with CodeQL` | CodeQL static analysis. Invoked by [call-orchestrator.yml](../../../../../.github/workflows/call-orchestrator.yml) so every CI run produces a single scan; additionally runs on a weekly cron so coverage does not drop when no pushes land on `main`. See [pipeline.md](../../../artefact/git/pipeline.md) for the gating behaviour. | reusable, auto (`schedule`: weekly Mon 00:00 UTC) | none |
| [call-lint.yml](../../../../../.github/workflows/call-lint.yml): `🧹 Lint` | Runs `make lint` (every lint check in parallel: action, ansible, javascript, makefile, markdown, mermaid, packages, playwright, python, shellcheck) plus a hadolint SARIF job on `Dockerfile`. | reusable, manual | none |
### CI images 🐳
@@ -49,15 +49,15 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched
| Workflow | Description | Trigger | Inputs |
|---|---|---|---|
| [call-images-build-ci.yml](../../../../../.github/workflows/call-images-build-ci.yml): `🖼️ Build: CI Images (all distros)` | Builds the per-distro CI base images consumed by all test jobs. | reusable | `distros` (required), `checkout_ref` (optional), `image_tag` (default `ci-${github.sha}`), `concurrency_channel` (default `default`) |
| [cron-images-cleanup-ci.yml](../../../../../.github/workflows/cron-images-cleanup-ci.yml): `⏰ Images: Cleanup CI (GHCR)` | Deletes CI images from GHCR older than N days. | auto (`schedule`: weekly Mon 00:00 UTC), manual | `days` (default `7`) |
| [cron-images-cleanup-ci.yml](../../../../../.github/workflows/cron-images-cleanup-ci.yml): `🧹 Prune CI images` | Deletes CI images from GHCR older than N days. | auto (`schedule`: weekly Mon 00:00 UTC), manual | `days` (default `7`) |
### Image mirroring 🪞
| Workflow | Description | Trigger | Inputs |
|---|---|---|---|
| [call-images-mirror-missing.yml](../../../../../.github/workflows/call-images-mirror-missing.yml): `🪞 Mirror: Docker Hub → GHCR (only missing)` | Mirrors only the upstream images that are not yet in GHCR. Called from the orchestrator before deploy tests. | reusable | `ghcr_namespace`, `ghcr_prefix` (default `mirror`), `repo_root` (default `.`), `source_repository`, `source_ref`, plus throttling knobs |
| [cron-images-mirror-all.yml](../../../../../.github/workflows/cron-images-mirror-all.yml): `⏰ Mirror: Docker Hub → GHCR` | Full nightly mirror of every referenced upstream image into GHCR. | auto (`schedule`: daily 00:00 UTC), manual | `ghcr_namespace`, `ghcr_prefix` (default `mirror`), `repo_root` (default `.`), `images_per_hour` (throttle) |
| [entry-manual-mirror-cleanup.yml](../../../../../.github/workflows/entry-manual-mirror-cleanup.yml): `🧹 Images: Cleanup GHCR (Private)` | Deletes GHCR mirror packages by prefix and visibility. Supports `dry_run`. | manual | `ghcr_namespace`, `ghcr_prefix` (default `mirror`), `visibility` (`private`/`public`/`internal`, default `private`), `dry_run` |
| [cron-images-mirror-all.yml](../../../../../.github/workflows/cron-images-mirror-all.yml): `🪞 Mirror all images` | Full nightly mirror of every referenced upstream image into GHCR. | auto (`schedule`: daily 00:00 UTC), manual | `ghcr_namespace`, `ghcr_prefix` (default `mirror`), `repo_root` (default `.`), `images_per_hour` (throttle) |
| [entry-manual-mirror-cleanup.yml](../../../../../.github/workflows/entry-manual-mirror-cleanup.yml): `🧹 Images: Cleanup GHCR` | Deletes GHCR mirror packages by prefix and visibility. Supports `dry_run`. | manual | `ghcr_namespace`, `ghcr_prefix` (default `mirror`), `visibility` (`private`/`public`/`internal`, default `private`), `dry_run` |
### Code tests 🧪
@@ -81,9 +81,9 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched
| Workflow | Description | Trigger | Inputs |
|---|---|---|---|
| [call-release-version.yml](../../../../../.github/workflows/call-release-version.yml): `🚀 Release: Version Logic` | Releases a specific version tag (build + publish). Called from `entry-push-latest.yml` on tagged pushes. | reusable, manual | `tag` (required, e.g. `v1.2.3`) |
| [cron-release-highest.yml](../../../../../.github/workflows/cron-release-highest.yml): `⏰ Release: Highest Missing Version` | Scheduled backfill: finds the highest tag without a release and triggers `call-release-version.yml` for it. | auto (`schedule`: daily 00:00 UTC), manual | none |
| [cron-cleanup-stale.yml](../../../../../.github/workflows/cron-cleanup-stale.yml): `⏰ Cleanup: Stale Repository Data` | Marks and closes stale issues and PRs, deletes inactive branches, and prunes old GHCR CI image versions. | auto (`schedule`: daily 00:00 UTC), manual | none |
| [cron-update.yml](../../../../../.github/workflows/cron-update.yml): `⏰ Update: Versions` | Updates Docker image versions and other pinned dependencies; opens an update PR via a GitHub App installation token so PR-lifecycle events fire on the resulting PR. Both jobs are gated behind the `CI_ENABLE_AUTO_UPDATES` repository variable, see [configuration.md](configuration.md). Requires the `BOT_APP_CLIENT_ID` and `BOT_APP_PRIVATE_KEY` repository secrets, see [secrets.md](secrets.md). | auto (`push` to `main`, `schedule`: daily 00:30 UTC), manual | none |
| [cron-release-highest.yml](../../../../../.github/workflows/cron-release-highest.yml): `🚀 Release missing tag` | Scheduled backfill: finds the highest tag without a release and triggers `call-release-version.yml` for it. | auto (`schedule`: daily 00:00 UTC), manual | none |
| [cron-cleanup-stale.yml](../../../../../.github/workflows/cron-cleanup-stale.yml): `🧹 Prune stale data` | Marks and closes stale issues and PRs, deletes inactive branches, and prunes old GHCR CI image versions. | auto (`schedule`: daily 00:00 UTC), manual | none |
| [cron-update.yml](../../../../../.github/workflows/cron-update.yml): `🔄 Update versions` | Updates Docker image versions and other pinned dependencies; opens an update PR via a GitHub App installation token so PR-lifecycle events fire on the resulting PR. Both jobs are gated behind the `CI_ENABLE_AUTO_UPDATES` repository variable, see [configuration.md](configuration.md). Requires the `BOT_APP_CLIENT_ID` and `BOT_APP_PRIVATE_KEY` repository secrets, see [secrets.md](secrets.md). | auto (`push` to `main`, `schedule`: daily 00:30 UTC), manual | none |
| [entry-pr-open-dependabot-close.yml](../../../../../.github/workflows/entry-pr-open-dependabot-close.yml): `🚫 Dependabot: Close while auto-updates disabled` | Auto-closes Dependabot PRs while `CI_ENABLE_AUTO_UPDATES` is not set to `true`, so Dependabot honours the same gate as the workflow-driven update jobs. See [configuration.md](configuration.md). | auto (`pull_request_target`: opened, reopened) | none |
## Changing workflows ✍️
+1 -1
View File
@@ -11,7 +11,7 @@ This directory holds the shell helpers that GitHub Actions workflows under [`.gi
| [cleanup/](cleanup/) | Scheduled repository maintenance that prunes stale GitHub resources. |
| [resolve/](resolve/) | Derivation of structured workflow inputs and outputs from repository state. |
| [sync/](sync/) | Ref synchronization before workflow jobs derive CI scope from git history. |
| [update/](update/) | The scheduled `Update: Versions` pipeline (version bumps and PR open/refresh). |
| [update/](update/) | The scheduled `🔄 Update versions` pipeline (version bumps and PR open/refresh). |
| [release/](release/) | Release-time gates that decide whether a tagged commit may be released. |
A new helper MUST live in the subfolder that matches its responsibility. New responsibilities MAY motivate a new subfolder, but MUST NOT flatten back into the top level.
+2 -2
View File
@@ -1,10 +1,10 @@
# Update Pipeline 🔄
Shell helpers that drive the scheduled `Update: Versions` workflow.
Shell helpers that drive the scheduled `🔄 Update versions` workflow.
## Scope 📋
This directory contains the helpers invoked by [cron-update.yml](../../../.github/workflows/cron-update.yml) to bump pinned upstream versions and open a pull request once per day. The helpers are thin wrappers: the image-version and repository-ref bumpers delegate to Python CLIs under [cli/contributing/](../../../cli/contributing/) that scan `roles/*/meta/services.yml` and rewrite version pins in place; the PR-open helper commits, force-pushes the update branch, and creates or refreshes the PR via `gh`. Helpers MUST NOT be called from workflows other than `⏰ Update: Versions`; the `CI_ENABLE_AUTO_UPDATES` gate is enforced once, at the workflow's job level, and bypassing it from another caller would create PRs even when auto-updates are intentionally disabled.
This directory contains the helpers invoked by [cron-update.yml](../../../.github/workflows/cron-update.yml) to bump pinned upstream versions and open a pull request once per day. The helpers are thin wrappers: the image-version and repository-ref bumpers delegate to Python CLIs under [cli/contributing/](../../../cli/contributing/) that scan `roles/*/meta/services.yml` and rewrite version pins in place; the PR-open helper commits, force-pushes the update branch, and creates or refreshes the PR via `gh`. Helpers MUST NOT be called from workflows other than `🔄 Update versions`; the `CI_ENABLE_AUTO_UPDATES` gate is enforced once, at the workflow's job level, and bypassing it from another caller would create PRs even when auto-updates are intentionally disabled.
Each shim MUST resolve the repository root relative to its own location so it can run from any working directory, MUST forward arguments to the underlying CLI so `--dry-run` and similar flags keep working from the shell, and MUST exit non-zero on CLI failure so the workflow surfaces the error.
@@ -0,0 +1,82 @@
"""Lint: a manually startable workflow carries a name short enough for the menu.
Scope
=====
Every `.github/workflows/*.yml` whose `on:` block declares `workflow_dispatch`.
Rule
====
Its `name:` is at most 25 characters.
Why
===
The dispatch menu ("Run workflow" in the Actions sidebar) lists every such
workflow in one narrow column. A longer name is cut off there, so the list
stops being scannable exactly where a human picks what to start. The `run-name:`
carries no such limit: it is read on the run itself, one per line, and MAY be
as long and explanatory as the run deserves.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from ruamel.yaml import YAML
from utils.cache.files import iter_project_files
from . import PROJECT_ROOT
from .test_workflow_trigger_prefix import trigger_names
_WORKFLOW_DIR = ".github/workflows/"
_DISPATCH = "workflow_dispatch"
_MAX_LEN = 25
_yaml = YAML(typ="safe")
_yaml.allow_duplicate_keys = True
def _load(path: str):
try:
with Path(path).open(encoding="utf-8") as handle:
return _yaml.load(handle)
except Exception:
return None
def too_long(data) -> str | None:
"""The offending name, or ``None`` when the workflow keeps to the limit."""
if not isinstance(data, dict) or _DISPATCH not in trigger_names(data):
return None
name = data.get("name")
if isinstance(name, str) and len(name) > _MAX_LEN:
return name
return None
class TestWorkflowDispatchNameLength(unittest.TestCase):
def test_dispatchable_workflow_names_fit_the_menu(self) -> None:
offenders: list[str] = []
for path_str in iter_project_files(extensions=(".yml", ".yaml")):
rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix()
if not rel.startswith(_WORKFLOW_DIR):
continue
name = too_long(_load(path_str))
if name:
offenders.append(f"{rel}: {name!r} is {len(name)} characters")
if offenders:
self.fail(
f"{len(offenders)} workflow(s) with a `{_DISPATCH}` trigger carry "
f"a `name:` longer than {_MAX_LEN} characters. The dispatch menu "
f"lists them in one narrow column, so a longer name is truncated "
f"there and the menu becomes unreadable exactly where a human "
f"picks what to start. Shorten `name:`; the longer, explanatory "
f"wording belongs in `run-name:`, which is read on the run itself "
f"and MAY be as long as it needs to be:\n" + "\n".join(sorted(offenders))
)
if __name__ == "__main__":
unittest.main()
@@ -9,8 +9,10 @@ Rule
The file name carries exactly one of three prefixes, derived from the `on:`
block and checked in this order:
* ``cron-`` the workflow has a `schedule` trigger. Its `name:` must also start
with ⏰, so a run nobody triggered is recognisable in the Actions list.
* ``cron-`` the workflow has a `schedule` trigger. Its `name:` carries a symbol
describing what it does, and its `run-name:` prefixes ⏰ only when
`github.event_name` is `schedule`, so a run nobody triggered is recognisable
in the Actions list while a dispatched one is not mislabelled as scheduled.
* ``call-`` no schedule, but a `workflow_call` trigger: a pipeline stage that
only another workflow starts. An extra `workflow_dispatch` for debugging does
not change that.
@@ -36,6 +38,7 @@ from . import PROJECT_ROOT
_WORKFLOW_DIR = ".github/workflows/"
_CLOCK = "⏰"
_SCHEDULE_GUARD = "github.event_name == 'schedule'"
_SCHEDULE = "cron-"
_REUSABLE = "call-"
_ENTRY = "entry-"
@@ -52,7 +55,7 @@ def _load(path: str):
return None
def _trigger_names(data) -> set[str]:
def trigger_names(data) -> set[str]:
"""The keys of the `on:` block, which YAML 1.1 parsers hand back under `True`."""
if not isinstance(data, dict):
return set()
@@ -69,7 +72,7 @@ def _trigger_names(data) -> set[str]:
def expected_prefix(data) -> str:
triggers = _trigger_names(data)
triggers = trigger_names(data)
if "schedule" in triggers:
return _SCHEDULE
if "workflow_call" in triggers:
@@ -89,8 +92,13 @@ def issues(rel: str, data) -> list[str]:
found.append(f"carries {carried!r}, must carry {wanted!r}")
if wanted == _SCHEDULE:
title = data.get("name") if isinstance(data, dict) else None
if not isinstance(title, str) or not title.startswith(_CLOCK):
found.append(f"name: must start with {_CLOCK}")
if isinstance(title, str) and title.startswith(_CLOCK):
found.append(f"name: must start with a descriptive symbol, not {_CLOCK}")
run_name = data.get("run-name") if isinstance(data, dict) else None
if not isinstance(run_name, str) or not (
_CLOCK in run_name and _SCHEDULE_GUARD in run_name
):
found.append(f"run-name: must gate {_CLOCK} behind {_SCHEDULE_GUARD!r}")
return found
@@ -109,7 +117,8 @@ class TestWorkflowTriggerPrefix(unittest.TestCase):
self.fail(
f"{len(offenders)} workflow(s) break the prefix convention. A "
f"workflow file MUST start with {_SCHEDULE!r} when it has a "
f"`schedule` trigger (and then carry a {_CLOCK} name), with "
f"`schedule` trigger (and then carry a descriptive name plus a "
f"`run-name:` that gates {_CLOCK} behind {_SCHEDULE_GUARD!r}), with "
f"{_REUSABLE!r} when it is only reachable through `workflow_call`, "
f"and with {_ENTRY!r} otherwise:\n" + "\n".join(sorted(offenders))
)