diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33eda65..e07bae8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,12 @@ name: CI +# The Actions list titles every run with the COMMIT MESSAGE, so four different workflows +# triggered by one push read as four identical entries -- which is how a merge came to look +# like a job running four times. `run-name` replaces that title with what the run IS. +# +# Deliberately NOT `github.event.head_commit.message`: this repository writes long, +# multi-paragraph commit messages, the title would swallow the list, and GitHub already +# shows the commit beside every run. The workflow's identity is the part that was missing. +run-name: "CI · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # ON EVERY MERGE, and on demand. A push to `main` is what a merge looks like from Actions' side, # so every merged PR now re-runs lint, the suite, and the build-identity check against the tree diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0e399b3..0870115 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,4 +1,5 @@ name: Code Quality +run-name: "Sonar/Snyk · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # SonarQube (static analysis + coverage) and Snyk (dependency vulnerabilities). # diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 28c578e..f1e14fa 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -1,4 +1,5 @@ name: Installer smoke +run-name: "Install smoke · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # The terminal installer's acceptance evidence (#479): "works on macOS and Linux". # diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml index b767074..46ed5f7 100644 --- a/.github/workflows/installer-smoke.yml +++ b/.github/workflows/installer-smoke.yml @@ -1,4 +1,5 @@ name: Installer smoke +run-name: "Installer smoke · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # Compiles packaging/keel.iss on a real Windows runner against a PLACEHOLDER bundle. # diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 8d3adf8..f23a6fb 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -1,4 +1,5 @@ name: Migrate database +run-name: "DB migration smoke test · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # Migrating a REAL database is manual only. Merging to `main` runs the migration-chain SMOKE TEST, # and structurally cannot do more than that -- see the safety property below. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a038114..23bc5b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ name: Release +run-name: "Release · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # MANUAL ONLY. Nothing about a money-moving tool should ship on a merge. # diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 838d16d..da5e8a7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -3,7 +3,7 @@ # Why this workflow exists: the pre-launch gate names a gap that has been true since the # repo opened -- `SONAR_TOKEN` and `SNYK_TOKEN` were names for secrets nobody created, so # keel had no static analysis or dependency scanning that actually runs. This workflow is -# the tokenless BASELINE: it runs on GITHUB_TOKEN alone, always, on every push and weekly. +# the tokenless BASELINE: it runs on GITHUB_TOKEN alone, on every pull request and weekly. # `code-quality.yml` remains the optional enhanced tier (SonarQube + Snyk) for IF those # tokens are ever created -- its preflight skips cleanly while they are not, so the two # workflows disagree about nothing: baseline here, optional depth there. @@ -12,10 +12,11 @@ # context `test`, which ci.yml produces. A vulnerable dependency or a CodeQL finding is # information about the state of the world, not a verdict on a proposed change -- a new # CVE published against an already-pinned version must surface without waiting for a -# merge to blame. So these jobs run alongside (on push, on PRs, and weekly on the -# calendar, because CVEs are published against pinned versions whether or not anyone -# pushes) without gating merges. +# merge to blame. So these jobs run alongside (on PRs, and weekly on the calendar, because +# CVEs are published against pinned versions whether or not anyone pushes) without gating +# merges. name: security +run-name: "Security audit · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" # ── WHY THIS SKIPS DOCUMENTATION, AND WHY `ci.yml` DELIBERATELY DOES NOT (#644) ────────────── # This workflow reads no documentation, so a docs-only change can produce no signal from it and @@ -37,21 +38,29 @@ name: security # # SAFE HERE for two independent reasons: `main` carries no branch protection, so no check is # "required" and a skipped run cannot leave a pull request permanently unmergeable; and -# `paths-ignore` applies only to `push`/`pull_request` -- `schedule` fires regardless, so the +# `paths-ignore` applies only to `pull_request` here -- `schedule` fires regardless, so the # weekly cron below re-scans everything within seven days even on a pathological miss. # # `docs/**` sweeps up the experiment drivers and ledgers under `docs/experiments/`. Correct # here, since this workflow reads none of it; NOT correct for `ci.yml`, where # `docs/experiments/trials-ledger.jsonl` is a hash-chained artifact the research tests reason # about. +# ── WHY THERE IS NO `push` TRIGGER ─────────────────────────────────────────────────────────── +# Both jobs ran a second time on the merge commit, and the second run cost ~2.5 minutes to +# re-answer a question the pull-request run had answered four minutes earlier: `dependencies` +# audits `uv.lock`, which the merge does not change, and neither job reads anything a merge +# could combine differently. Five merges on 2026-09-01 spent ~12 minutes that way. +# +# ⚠️ THE COST, STATED RATHER THAN DISCOVERED. Code scanning reports alerts against the DEFAULT +# BRANCH, and that baseline is refreshed by analyses OF the default branch -- which now means the +# weekly cron alone. A finding introduced by a merge, or an alert a merge fixes, can therefore +# take up to seven days to appear or clear in the Security tab. Pull-request analysis is +# unaffected and still runs on every change, so nothing reaches `main` unscanned; what is slower +# is the SUMMARY of what is on `main`, not the scanning itself. If that lag ever matters, put +# `push` back rather than shortening the cron -- a daily scan pays most of the cost for a +# fraction of the freshness. on: workflow_dispatch: - push: - branches: [main] - paths-ignore: - - "docs/**" - - "**/*.md" - - "LICENSE" pull_request: paths-ignore: - "docs/**" diff --git a/tests/test_workflow_triggers.py b/tests/test_workflow_triggers.py index 00ec392..e0fd7d2 100644 --- a/tests/test_workflow_triggers.py +++ b/tests/test_workflow_triggers.py @@ -1,4 +1,4 @@ -"""Which workflows may skip a documentation-only change, and which may never (#644). +"""Workflow-YAML invariants: which runs may skip documentation, and what a run is called. Three workflows read no documentation, so a docs-only change produces no signal from them and the run is pure cost. `ci.yml` is the opposite: keel's documentation is UNDER TEST, and @@ -28,6 +28,11 @@ _EXPECTED = {"docs/**", "**/*.md", "LICENSE"} +#: Every workflow, because the clutter this guards against is caused by the ones nobody thinks +#: about. Discovered from disk rather than listed, so a new workflow is covered on the day it +#: lands instead of the day someone remembers to add it here. +_ALL = sorted(p.name for p in _WORKFLOWS.glob("*.yml")) + def _triggers(name: str) -> dict: """The `on:` block. PyYAML resolves the bare key `on` to the boolean True (YAML 1.1), @@ -73,3 +78,34 @@ def test_ci_never_skips_a_documentation_change() -> None: f"ci.yml's `{event}` trigger has grown a paths allowlist, which skips everything " "NOT listed -- the same defect as paths-ignore, inverted (#644)." ) + + +# -- what a run is CALLED ---------------------------------------------------------------------- + + +def test_every_workflow_names_its_own_runs() -> None: + """Without `run-name`, the Actions list titles every run with the COMMIT MESSAGE. + + One push to `main` starts four workflows, and all four then appear under the same + `Merge pull request #NNN from ...` heading -- which reads as one job running four times + rather than four different checks running once. That is not a hypothetical: it is how this + pin came to exist. + + The workflow's own identity is the part the default title drops, so that is what + `run-name` has to carry. Asserted for EVERY workflow, including the ones that only run on + dispatch: a release run sitting in the same list under a commit message is the same + confusion, just rarer. + """ + assert _ALL, "no workflows found -- has .github/workflows moved?" + for name in _ALL: + doc = strict_load((_WORKFLOWS / name).read_text(encoding="utf-8"), source=name) + run_name = doc.get("run-name") + assert run_name, ( + f"{name} has no `run-name`, so its runs are titled with the commit message and are " + "indistinguishable from every other workflow the same push started" + ) + assert "github.event.head_commit.message" not in run_name, ( + f"{name}'s `run-name` interpolates the commit message. This repository writes long, " + "multi-paragraph commit messages and GitHub already shows the commit beside every " + "run -- putting it in the title restores exactly the clutter run-name removes" + )