From e7fadf02fe0be7027690da23e440d4e368c215ec Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 22 Aug 2026 11:18:54 +0200 Subject: [PATCH] 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) --- .github/workflows/call-test-runner-smoke.yml | 3 +- .github/workflows/cron-cleanup-stale.yml | 3 +- .github/workflows/cron-images-cleanup-ci.yml | 3 +- .github/workflows/cron-images-mirror-all.yml | 3 +- .github/workflows/cron-release-highest.yml | 3 +- .github/workflows/cron-security-codeql.yml | 3 +- .github/workflows/cron-security-scorecard.yml | 3 +- .github/workflows/cron-update.yml | 3 +- .../workflows/entry-manual-mirror-cleanup.yml | 3 +- .../artefact/files/github/workflow.md | 16 +++- .../tools/github/actions/workflows.md | 14 ++-- scripts/github/README.md | 2 +- scripts/github/update/README.md | 4 +- .../test_workflow_dispatch_name_length.py | 82 +++++++++++++++++++ .../test_workflow_trigger_prefix.py | 23 ++++-- 15 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 tests/lint/repository/test_workflow_dispatch_name_length.py diff --git a/.github/workflows/call-test-runner-smoke.yml b/.github/workflows/call-test-runner-smoke.yml index 5625fb1e4d..18f5065767 100644 --- a/.github/workflows/call-test-runner-smoke.yml +++ b/.github/workflows/call-test-runner-smoke.yml @@ -1,4 +1,5 @@ -name: "๐Ÿƒ Test: Runner smoke (self-hosted)" +name: "๐Ÿƒ Test: Runner smoke" +run-name: "๐Ÿƒ Test: Runner smoke (self-hosted)" on: workflow_dispatch: diff --git a/.github/workflows/cron-cleanup-stale.yml b/.github/workflows/cron-cleanup-stale.yml index 5c6dfbff6e..35a5d0168e 100644 --- a/.github/workflows/cron-cleanup-stale.yml +++ b/.github/workflows/cron-cleanup-stale.yml @@ -1,4 +1,5 @@ -name: "โฐ Cleanup: Stale Repository Data" +name: "๐Ÿงน Prune stale data" +run-name: "${{ github.event_name == 'schedule' && 'โฐ ' || '' }}๐Ÿงน Prune stale data" on: schedule: diff --git a/.github/workflows/cron-images-cleanup-ci.yml b/.github/workflows/cron-images-cleanup-ci.yml index 8b27431905..33b136a357 100644 --- a/.github/workflows/cron-images-cleanup-ci.yml +++ b/.github/workflows/cron-images-cleanup-ci.yml @@ -1,4 +1,5 @@ -name: "โฐ Images: Cleanup CI (GHCR)" +name: "๐Ÿงน Prune CI images" +run-name: "${{ github.event_name == 'schedule' && 'โฐ ' || '' }}๐Ÿงน Prune CI images" on: schedule: diff --git a/.github/workflows/cron-images-mirror-all.yml b/.github/workflows/cron-images-mirror-all.yml index 746a6f796b..af8df88f85 100644 --- a/.github/workflows/cron-images-mirror-all.yml +++ b/.github/workflows/cron-images-mirror-all.yml @@ -1,4 +1,5 @@ -name: "โฐ Mirror: Docker Hub โ†’ GHCR" +name: "๐Ÿชž Mirror all images" +run-name: "${{ github.event_name == 'schedule' && 'โฐ ' || '' }}๐Ÿชž Mirror all images" on: schedule: diff --git a/.github/workflows/cron-release-highest.yml b/.github/workflows/cron-release-highest.yml index 9551ff4e88..563e6e5b50 100644 --- a/.github/workflows/cron-release-highest.yml +++ b/.github/workflows/cron-release-highest.yml @@ -1,4 +1,5 @@ -name: "โฐ Release: Highest Missing Version" +name: "๐Ÿš€ Release missing tag" +run-name: "${{ github.event_name == 'schedule' && 'โฐ ' || '' }}๐Ÿš€ Release missing tag" on: schedule: diff --git a/.github/workflows/cron-security-codeql.yml b/.github/workflows/cron-security-codeql.yml index 0779b3577d..35f47e7b7f 100644 --- a/.github/workflows/cron-security-codeql.yml +++ b/.github/workflows/cron-security-codeql.yml @@ -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 }} diff --git a/.github/workflows/cron-security-scorecard.yml b/.github/workflows/cron-security-scorecard.yml index 068e8f55f8..69eb85746f 100644 --- a/.github/workflows/cron-security-scorecard.yml +++ b/.github/workflows/cron-security-scorecard.yml @@ -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 diff --git a/.github/workflows/cron-update.yml b/.github/workflows/cron-update.yml index 17766bde35..33ee4e3bcf 100644 --- a/.github/workflows/cron-update.yml +++ b/.github/workflows/cron-update.yml @@ -1,4 +1,5 @@ -name: "โฐ Update: Versions" +name: "๐Ÿ”„ Update versions" +run-name: "${{ github.event_name == 'schedule' && 'โฐ ' || '' }}๐Ÿ”„ Update versions" on: push: diff --git a/.github/workflows/entry-manual-mirror-cleanup.yml b/.github/workflows/entry-manual-mirror-cleanup.yml index 028af57c8e..b0b60f3fd6 100644 --- a/.github/workflows/entry-manual-mirror-cleanup.yml +++ b/.github/workflows/entry-manual-mirror-cleanup.yml @@ -1,4 +1,5 @@ -name: "๐Ÿงน Images: Cleanup GHCR (Private)" +name: "๐Ÿงน Images: Cleanup GHCR" +run-name: "๐Ÿงน Images: Cleanup GHCR (Private)" on: workflow_dispatch: diff --git a/docs/contributing/artefact/files/github/workflow.md b/docs/contributing/artefact/files/github/workflow.md index abbbe0a9ed..74548db793 100644 --- a/docs/contributing/artefact/files/github/workflow.md +++ b/docs/contributing/artefact/files/github/workflow.md @@ -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/`. diff --git a/docs/contributing/tools/github/actions/workflows.md b/docs/contributing/tools/github/actions/workflows.md index ce2005c6fa..47e41226f4 100644 --- a/docs/contributing/tools/github/actions/workflows.md +++ b/docs/contributing/tools/github/actions/workflows.md @@ -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 โœ๏ธ diff --git a/scripts/github/README.md b/scripts/github/README.md index b8a3c072cc..e69221675c 100644 --- a/scripts/github/README.md +++ b/scripts/github/README.md @@ -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. diff --git a/scripts/github/update/README.md b/scripts/github/update/README.md index 9392b035e3..7ff99b864c 100644 --- a/scripts/github/update/README.md +++ b/scripts/github/update/README.md @@ -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. diff --git a/tests/lint/repository/test_workflow_dispatch_name_length.py b/tests/lint/repository/test_workflow_dispatch_name_length.py new file mode 100644 index 0000000000..b3a5b8d712 --- /dev/null +++ b/tests/lint/repository/test_workflow_dispatch_name_length.py @@ -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() diff --git a/tests/lint/repository/test_workflow_trigger_prefix.py b/tests/lint/repository/test_workflow_trigger_prefix.py index 9ba423b7fa..afe88324cf 100644 --- a/tests/lint/repository/test_workflow_trigger_prefix.py +++ b/tests/lint/repository/test_workflow_trigger_prefix.py @@ -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)) )