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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
@@ -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).
#
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/install-smoke.yml
Original file line number Diff line number Diff line change
@@ -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".
#
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/installer-smoke.yml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/migrate.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand Down
31 changes: 20 additions & 11 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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/**"
Expand Down
38 changes: 37 additions & 1 deletion tests/test_workflow_triggers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"
)
Loading