Skip to content

feat(scheduled-failures): report failing scheduled workflow runs to Matrix - #44

Open
CybotTM wants to merge 9 commits into
mainfrom
feat/scheduled-failure-notifications
Open

feat(scheduled-failures): report failing scheduled workflow runs to Matrix#44
CybotTM wants to merge 9 commits into
mainfrom
feat/scheduled-failure-notifications

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 10, 2026

Copy link
Copy Markdown
Member

Why

netresearch.github.io's nightly Build & Deploy was red for 13 days and 14 consecutive runs before anyone noticed. GitHub's own notification for a failing scheduled run goes to whoever last edited the workflow file, which for a shared reusable workflow is frequently nobody who watches that repo — so the signal reached no one. Scheduled runs are the organisation's early-warning system for dependency drift, because they install fresh where PR runs sit on warm caches; losing that signal is what let two more repos sit red for days after a PHPUnit release.

Run against the live organisation, this branch finds that incident exactly — netresearch.github.io / Build & Deploy (14x, since 2026-07-28 (13 days)) — plus seven more failing scheduled workflows nobody was tracking — including skill-repo-skill / A/B Evals Schedule, red since 2026-07-27 under a workflow confirmed active.

What it does

A daily job walks every non-archived repo in the org and reports transitions, not states, so a workflow that is still red does not produce a message on every poll: 🔴 on green → red, 🔁 one reminder per week while it stays red, 🟢 on recovery, 🗄 when a still-failing workflow is retired, and 📋 a single baseline summary on a first run or an expired state artifact. Every failure message carries the repository, the workflow name, the consecutive-failure count, the date the streak started and a link to the run, so triage does not require opening GitHub. Repositories with no scheduled runs are skipped and never reported.

Runs concluding cancelled, skipped, neutral, stale or action_required are ignored in both directions — they are verdicts on GitHub's plumbing rather than on the software, so counting them as red would generate noise and counting them as green would silently reset a real failure streak.

Reuse rather than a second mechanism

check-stars.py's rate-limit policy from #43 — a 60-second floor for secondary limits, Retry-After and x-ratelimit-reset handling, a 600-second per-run sleep budget and a give-up message that names the limit — moves to scripts/github_api.py and is now shared by both scripts rather than reimplemented weaker. The move is docstring-only: is_rate_limited, parse_retry_after, rate_limit_reset_text, rate_limit_wait, spend_rate_limit_budget, describe_rate_limit, github_request and RateLimitError are byte-identical to their previous definitions, verified by AST-level comparison against origin/main. Matrix posting follows check-stars.py's conventions and the repo iteration follows collect_impact.py's; MATRIX_WEBHOOK_URL is the existing secret.

The extraction surfaced one real defect, caught by ruff check: get_dependents scrapes github.com HTML rather than the API and therefore calls parse_retry_after directly, which made it the one caller the move left behind. It is fixed and now covered by a regression test that fails with NameError when the import is removed again.

Design decisions

State: a workflow artifact. Matches star-notifications.yml. A committed state file was rejected because it would put a commit on main on every run; the Actions cache was rejected because entries are evicted after 7 days without a read, which is shorter than the 7-day reminder interval this has to measure. An artifact can still expire — handled by the baseline summary, which names everything already failing in one line instead of re-announcing each as a fresh break. The file's contents are validated on load rather than trusted, so a malformed artifact degrades to a baseline instead of raising an AttributeError several hundred API calls into a run.

Retired workflows are excluded, by GitHub's answer and never by age. The first baseline this PR produced contained its own counter-example: ofelia / Cleanup Container Images reported as failing for months. It is not a failing workflow — a netresearch/.github template sync on 2026-04-19/20 renamed it to Container Retention at .github/workflows/container-retention.yml, which is alive and active; only the old identity's run history is frozen red. It would have earned a weekly reminder in perpetuity about something nobody can action. The rule needs both halves, and each catches a live case: the id has no entry in GET /actions/workflows (renamed or removed — ofelia), or that entry's state is not active (disabled_manuallyclaude-code-marketplace-P / Pages; also disabled_inactivity, GitHub's 60-day auto-pause, and disabled_fork). A disabled workflow still appears in the list, so the first half alone would miss it entirely. Keyed on workflow id, not name: an id follows the file, so a sync that edits name: in place keeps the id and the entry is correctly kept, whereas name-matching would retire a workflow still running on schedule — there is a test for exactly that. There is deliberately no age threshold: it is wrong in both directions, since a monthly cron whose last run is 45 days old is alive and must still be reported while a workflow retired yesterday is already dead, and GitHub's state is precisely what separates retired from merely dormant. Because an absent id means "retired", a workflow list wrong in the short direction would silence real failures, so anything less than a confidently complete list keeps every entry: a failed request, pages that do not add up to total_count (netresearch/.github has 63 workflows against a 100-item page, so the list is read to the end), or an empty list for a repo that demonstrably has scheduled runs. The exclusion is never silent — logged every cycle, named in the baseline message, and a workflow that was being actively reported when it retires gets one closing 🗄 message rather than just ceasing to appear. Re-adding or re-enabling brings it back on the next poll. Trade-off accepted: a repo GitHub auto-paused for inactivity stops being reported, correct in the narrow sense but it does mean a dormant repo's broken nightly goes quiet.

The verdict is read from the newest run, not the first one listed. While verifying the above, a dry run reported t3x-rte_ckeditor_image's weekly CI as "failing 1x since 2026-07-13" when that run is four back and the newest is a success. Twelve consecutive queries afterwards were correctly ordered, so the trigger is not established and the commit does not claim one — but summarise_workflow documented "newest run first" as a contract and then relied entirely on the endpoint to honour it. Every answer it gives is an ordering claim (which run is latest, whether it is red at all, how long the streak is, when it started), so one mis-ordered response turns all four wrong at once, silently: a false red, then a false recovery once the order corrects. It now sorts by timestamp before reading. The test drives the same runs in three orders and requires the same verdict from each.

Schedule: daily at 07:00 UTC. The runs being watched are overwhelmingly nightly, so polling faster buys no detection speed and just multiplies the ~280 requests a pass costs; the star notifier's 15-minute cadence would be tens of thousands a day for a signal that changes once. Against a 13-day incident, a worst case of 24 hours is not the constraint worth optimising, and 07:00 UTC puts the report at the start of the CET working day rather than at 03:00.

Two requests per repo that has schedules, one for a repo that does not. The runs page plus the workflow list — the list is the only way to answer "can this still run", and it doubles as the index for the crowded-page gapfill, so it is fetched once and used twice. The gapfill matters because one runs page stops covering a repo as soon as a high-frequency workflow crowds it: in netresearch/maint, 98 of the 100 most recent scheduled runs are Star Notifications and only 2 are the nightly Impact Dashboard, so a nightly sibling can vanish from the page entirely. When the page is truncated every workflow missing from it is queried on its own, and when a failure streak runs past the window it is reported as "at least N" rather than stated as a total.

Verification

Verified at de1f3cd927913b088dc10a26fb950e1c420205da. No message was ever sent to the real Matrix room.

  • 44 unit tests (python -m pytest tests/), all green, none making a network call. Both commits are independently green on their own tree. Mutation probes confirm the tests fail in position: stamping last_notified on every cycle, dropping the transition check, disabling the crowded-page gapfill, treating cancelled as a failure, removing the parse_retry_after import, removing the retirement check, treating an unreadable or truncated workflow list as "everything retired", matching on workflow name instead of id, and removing the sort each break exactly the test claiming to guard them.
  • A live --dry-run over 192 non-archived repos, read-only. The reported set matches an independently instrumented walk of the same org exactly: 8 failing, 2 excluded and named.
  • Three full non-dry runs against a local HTTP stub standing in for the Hookshot webhook, on live GitHub data. Run 1: baseline, 1 message. Run 2 immediately after: 0 messages, 186 unchanged, workflow map byte-identical to run 1's. Run 3, after backdating the notification stamps by eight days: exactly the 10 weekly reminders due. Requirements 2, 3 and 4 proven end to end rather than only in unit tests.

The exact pip install line was run in a clean Python 3.14 venv: wheels exist for every pinned dependency and the hashes verify. actionlint passes on all workflows — it caught that --only-binary :all: parses as a YAML mapping unless the run: is a block scalar, which would have broken the workflow outright. ruff check reports 4 findings, all pre-existing and identical to origin/main (I001 in backfill_archived_history.py, DTZ003 in check-stars.py, F541 in collect_impact.py, B023 in test_check_stars.py) — this branch adds none.

Before this can work

SCHEDULED_FAILURES_PAT does not exist yet and must be created: a fine-grained PAT on the netresearch org, all repositories, Metadata: read + Actions: read. The job's own GITHUB_TOKEN is scoped to this repository and cannot read another repository's workflow runs, and reusing STAR_NOTIFICATIONS_PAT or IMPACT_DASHBOARD_PAT would couple three schedules to one token's expiry. Until the secret exists the workflow fails with an ::error:: naming it. Setup steps are in the README.

Notes for the reviewer

The brief asked for ruff format as well as ruff check. The repo has no ruff configuration, no pyproject and no lint job, and existing files run to 225 characters, so ruff format at its 88-column default would reformat all eight files — and even at the repo's own width it flips quote styles across existing code. I ran ruff check and left formatting alone rather than bury this change under a repo-wide reformat.

Two things this PR deliberately does not do, both worth their own change. Nothing in CI runs tests/ today, which is the same class of problem this PR is about — a signal nobody receives. And star-notifications.yml and impact-dashboard.yml still install dependencies unpinned and sdist-allowed; Sonar only gates new code so they are not flagged, but the hardening applies equally. I left them out because wheel availability for beautifulsoup4 and pyyaml under --only-binary on 3.14 is unverified and a broken nightly dashboard does not belong in this diff.

check-stars.py's rate-limit policy — a 60-second floor for secondary
limits, Retry-After and x-ratelimit-reset handling, a 600-second per-run
sleep budget and a give-up message that names the limit — was paid for in
two broken scheduled runs. A second script needs the same GitHub client,
and a second implementation of that policy would simply re-earn those
failures, so the client moves to scripts/github_api.py and check-stars.py
imports it.

The move is docstring-only: is_rate_limited, parse_retry_after,
rate_limit_reset_text, rate_limit_wait, spend_rate_limit_budget,
describe_rate_limit, github_request and RateLimitError are byte-identical
to their previous definitions.

Two additions come with it. post_to_matrix now passes a timeout, which
the inline webhook POST did not: a wedged webhook used to hold the runner
open until the Actions timeout. reset_rate_limit_budget exists so tests
cannot inherit each other's budget through the module global.

The rate-limit tests move to tests/test_github_api.py, where the code
they exercise now lives, and gain two cases: a dict body must cost
exactly one request (the Actions endpoints return an envelope, and
paginating it would walk thousands of runs), and post_to_matrix must set
a timeout.

tests/test_check_stars.py gains a regression test for the scrape path in
get_dependents. It calls parse_retry_after directly, because it scrapes
github.com HTML rather than the API, which made it the one caller the
extraction left behind; the break is only reachable through a transient
5xx, so nothing else provoked it.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
netresearch.github.io's nightly Build & Deploy was red for 13 days and 14
consecutive runs before anyone noticed. GitHub notifies whoever last
edited the workflow file, which for a shared reusable workflow is
frequently nobody who watches that repo, so the signal reached no one —
and scheduled runs are the org's early-warning system for dependency
drift, since they install fresh where PR runs sit on warm caches.

A daily job walks every non-archived repo in the org and reports
transitions rather than states: green -> red once, one reminder a week
while it stays red, and a short note on recovery. Each failure message
carries the repo, the workflow, the consecutive-failure count, the date
the streak started and a link to the run, so triage does not need
GitHub. Repos with no scheduled runs are skipped, not reported.

Design points worth knowing:

State lives in a workflow artifact, matching star-notifications.yml. A
committed file would put a commit on main every run; the Actions cache is
evicted after 7 days without a read, which is shorter than the reminder
interval this has to measure. An artifact can still expire, so a missing
baseline produces one summary line naming everything already failing
rather than re-announcing each as a new break.

Daily at 07:00 UTC, because the runs being watched are overwhelmingly
nightly: polling faster buys no detection speed against a 13-day
incident and just multiplies the ~200 requests a pass costs. 07:00 UTC
puts the report at the start of the CET working day.

One request per repo covers the whole history until a high-frequency
workflow crowds the page. In netresearch/maint, 98 of the 100 most
recent scheduled runs are Star Notifications and only 2 are the nightly
Impact Dashboard, so a nightly sibling can fall off the page entirely.
When the page is truncated the workflow list is fetched and each missing
workflow is queried on its own; when the count of failures runs past the
window it is reported as a lower bound rather than as a total.

Runs concluding cancelled, skipped, neutral, stale or action_required
are ignored in both directions. They are verdicts on GitHub's plumbing,
not on the software: counting them red would generate noise, counting
them green would silently reset a real failure streak.

Needs a new SCHEDULED_FAILURES_PAT secret with Metadata: read and
Actions: read on the org; the job's own GITHUB_TOKEN is scoped to this
repository and cannot read another repository's workflow runs.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
actions/actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 🟢 6.9
Details
CheckScoreReason
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 10all changesets reviewed
Maintained🟢 1024 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
Packaging⚠️ -1packaging workflow not detected
License🟢 10license file detected
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 10SAST tool is run on all commits
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
actions/actions/setup-python 5fda3b95a4ea91299a34e894583c3862153e4b97 🟢 6.6
Details
CheckScoreReason
Code-Review🟢 10all changesets reviewed
Maintained🟢 1017 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Binary-Artifacts🟢 10no binaries found in the repo
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Packaging⚠️ -1packaging workflow not detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies🟢 7dependency not pinned by hash detected -- score normalized to 7
License🟢 10license file detected
Fuzzing⚠️ 0project is not fuzzed
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
SAST🟢 9SAST tool is not run on all commits -- score normalized to 9
actions/actions/upload-artifact 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 🟢 5.2
Details
CheckScoreReason
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Packaging⚠️ -1packaging workflow not detected
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Code-Review🟢 10all changesets reviewed
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies⚠️ 1dependency not pinned by hash detected -- score normalized to 1
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 10SAST tool is run on all commits
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
actions/dawidd6/action-download-artifact b6e2e70617bc3265edd6dab6c906732b2f1ae151 🟢 4.1
Details
CheckScoreReason
Code-Review⚠️ 1Found 1/7 approved changesets -- score normalized to 1
Maintained🟢 68 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 6
Packaging⚠️ -1packaging workflow not detected
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Security-Policy⚠️ 0security policy file not detected
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
Binary-Artifacts🟢 10no binaries found in the repo
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
SAST🟢 9SAST tool is not run on all commits -- score normalized to 9
Fuzzing⚠️ 0project is not fuzzed
pip/certifi 2026.7.22 🟢 6.4
Details
CheckScoreReason
Binary-Artifacts🟢 10no binaries found in the repo
Maintained🟢 1013 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Code-Review🟢 3Found 1/3 approved changesets -- score normalized to 3
Security-Policy🟢 10security policy file detected
Token-Permissions🟢 10GitHub workflow tokens follow principle of least privilege
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Pinned-Dependencies🟢 5dependency not pinned by hash detected -- score normalized to 5
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
Signed-Releases⚠️ -1no releases found
License🟢 9license file detected
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
Packaging🟢 10packaging workflow detected
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0
pip/charset-normalizer 3.4.9 UnknownUnknown
pip/idna 3.18 UnknownUnknown
pip/requests 2.34.2 UnknownUnknown
pip/urllib3 2.7.0 UnknownUnknown

Scanned Files

  • .github/workflows/scheduled-failure-notifications.yml
  • requirements/scheduled-failures.txt

The quality gate came back at security rating E on new code. All three
findings are real; none is silenced.

S2083 (blocker, path injection): STATE_FILE was built from an
environment variable and then written to, so anything able to set that
variable chose where the process writes. The override existed only for
test convenience the tests do not need — they rebind the module
attribute directly — so the path is now fixed, matching check-stars.py.

S8541 and S8544 (dependency install): `pip install requests` resolved
whatever the index offered at run time and would happily build an sdist,
executing its setup.py on the runner. Installs now come from a
hash-pinned requirements file with --require-hashes and
--only-binary :all:, which fixes the entire resolved tree rather than
the top-level name. Renovate's config:recommended already manages
pip requirements files, so this stays current without manual bumps.

Verified by running the exact install command in a clean Python 3.14
venv: wheels exist for every pinned dependency and the hashes check out.
The install line needs a block scalar — actionlint caught that `:all:`
parses as a YAML mapping otherwise, which would have broken the workflow
outright.

The two older workflows install the same way and are not touched here:
Sonar only gates new code, bs4/pyyaml wheel availability under
--only-binary on 3.14 is unverified, and unrelated workflow changes do
not belong in this PR. Worth its own change.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Comment thread scripts/check_scheduled_failures.py Fixed
Fixing the path did not clear Sonar's S2083: reading the flow rather than
the rule title shows the source is the state file's CONTENTS, not its
name. json.load returns whatever the downloaded artifact held, that
object becomes `state`, and `state` goes straight back into the write.

The analyzer is pointing at a real weakness even if path traversal is
not it. The rest of the module reads that object assuming a shape —
previous.get("failing"), a timedelta against last_notified, an int
failure count — so one malformed entry was an AttributeError waiting to
kill a run several hundred API calls in, at the point where the state
save happens and the run would otherwise have been useful.

load_state now rebuilds the map from the four fields the transition
rules actually read, coercing each and re-emitting timestamps from
parsed datetimes; a file holding a list, or an entry that is not an
object, degrades to an empty baseline with a warning. save_state takes
the new workflow map rather than the loaded dict, so nothing from the
artifact is carried back into the write untouched. Reads and writes go
through open(..., encoding="utf-8") because a JSON file should not be
decoded with whatever the runner's locale happens to be.

state_entry normalises failing_since on the way in, which the round-trip
test caught: GitHub sends "...Z", the loader re-emits "...+00:00", and
without normalising the file churned representation on every reload.

Verified against the live organisation through a local webhook stub: a
baseline run posts one summary, an immediate second run posts nothing
and reports 186 unchanged with a byte-identical workflow map, and after
backdating the notification stamps by eight days a third run posts
exactly the 10 weekly reminders — including the "at least 5" lower bound
for ofelia, whose failure streak runs past the fetch window.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…ment

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
The baseline this PR produced contained its own counter-example.
netresearch/ofelia's "Cleanup Container Images" was reported as failing
for 141 days, but it is not a workflow failing for 141 days — it is a
workflow deleted in April whose last run happens to be red and always
will be. Under the previous logic that entry stayed red forever and
earned a weekly reminder in perpetuity: noise nobody can action, in the
channel this exists to keep credible. Someone mutes it, and the real
signals go with it.

Measuring all ten baseline entries found two shapes, not one. The id is
absent from GET /actions/workflows, meaning the file was deleted
(ofelia); or the state is not `active` — disabled_manually,
disabled_inactivity, disabled_fork (claude-code-marketplace-P / Pages).
The other eight are live and stay.

So the rule is GitHub's own answer, with deliberately no age threshold.
A threshold gets it wrong both ways: a monthly cron whose last run is 45
days old is alive and must still be reported, while a workflow deleted
yesterday is already dead. It is also precisely what separates retired
from merely dormant.

The exclusion is never silent — that would be the same disappearing act
in a quieter register. It is logged every cycle, named in the baseline
message, and a workflow that was being actively reported when it retires
gets one closing message instead of just ceasing to appear. Re-adding or
re-enabling brings it back on the next poll.

Cost: the workflow list is now fetched for every repo that has scheduled
runs, two requests instead of one across ~79 repos. The list doubles as
the index for the crowded-page gapfill, so it is fetched once and used
twice, and a repo with no scheduled runs still costs exactly one.

Live dry run: 10 reported entries become 8, with both exclusions named.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
…sted

While verifying the retired-workflow change, a dry run reported
netresearch/t3x-rte_ckeditor_image's weekly CI as "failing 1x since
2026-07-13". It is not failing: that run is four back, and the newest is
a success from 2026-08-10. The same query fifteen minutes later, and
twelve times consecutively after that, came back correctly ordered, so
the trigger is not established and this commit does not claim one.

What is established is that nothing made the right answer inevitable.
summarise_workflow documented "newest run first" as a contract and then
relied entirely on the endpoint to honour it — no sort anywhere. Every
answer it produces is an ordering claim: which run is latest, whether
the workflow is red at all, how long the streak is, when it started. One
mis-ordered response turns all four wrong at once, and the failure is
silent: a false red, then a false recovery on the following run once the
order corrects.

Sorting the runs by timestamp before reading them costs one sort per
workflow and removes the assumption entirely. The test drives the same
runs in three orders — newest first, oldest first, failure first — and
requires the same verdict from each; without the sort it fails on the
live case's exact shape.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Comment thread tests/test_check_scheduled_failures.py Fixed
CodeQL flagged py/incomplete-url-substring-sanitization (high) on
`"netresearch.github.io" in message`: a substring check against a
literal that parses as a hostname is the shape of a URL-sanitisation
bypass, and the rule cannot tell that this one is asserting a chat
message names a repository.

The literal was duplicating REPO["name"] anyway, so the assertions now
read it from the fixture. That clears the pattern at the source rather
than dismissing the alert, and the tests no longer restate a value they
already have.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
CybotTM added a commit that referenced this pull request Aug 10, 2026
`tests/` exists and passes, but none of the four workflows referenced
pytest, unittest or `tests/` — so the suite had never been executed by
CI. Two changes to production-behaviour scripts were merged with tests
attached that nothing ran.

This adds `.github/workflows/tests.yml`, running `pytest tests/` on
every pull request and on pushes to `main`, with `contents: read` and
both actions pinned to the same SHAs the sibling workflows already use.

Dependencies come from a new `requirements/tests.txt`, hash-pinned and
wheels-only. `beautifulsoup4` is not optional there:
`tests/test_check_stars.py` imports `scripts/check-stars.py` at module
scope, so in a venv without it pytest fails during collection rather
than in a single test — verified, `ModuleNotFoundError: No module named
'bs4'` at `scripts/check-stars.py:11`, `Interrupted: 1 error during
collection`.

## Verification

Run locally in a clean venv, with the same two commands the workflow
issues:

```
$ pip install --only-binary :all: --require-hashes -r requirements/tests.txt
Successfully installed beautifulsoup4-4.15.0 certifi-2026.7.22 charset-normalizer-3.4.9 idna-3.18 iniconfig-2.3.0 packaging-26.3 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1 requests-2.34.2 soupsieve-2.9.2 typing-extensions-4.16.0 urllib3-2.7.0

$ pytest tests/ -v
platform linux -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
collected 10 items
tests/test_check_stars.py::test_empty_repo_returns_no_dependents_without_scraping PASSED
tests/test_check_stars.py::test_get_org_repos_maps_archived_flag PASSED
tests/test_check_stars.py::test_archived_repo_skips_token_gated_fetches PASSED
tests/test_check_stars.py::test_per_repo_autherror_is_skipped_not_fatal PASSED
tests/test_check_stars.py::test_rate_limit_wait_prefers_retry_after PASSED
tests/test_check_stars.py::test_rate_limit_wait_waits_for_the_primary_reset PASSED
tests/test_check_stars.py::test_rate_limit_wait_floors_secondary_backoff_at_a_minute PASSED
tests/test_check_stars.py::test_secondary_rate_limit_waits_minutes_and_stays_within_budget PASSED
tests/test_check_stars.py::test_rate_limit_give_up_message_names_the_cause PASSED
tests/test_check_stars.py::test_permission_403_is_still_an_autherror PASSED
============================== 10 passed in 0.12s ==============================
```

`actionlint .github/workflows/tests.yml` exits 0.

## Notes

No `ruff format` was run and no ruff config was added — at ruff's
88-column default it would reformat every existing file, and this PR
adds no Python.

`persist-credentials: false` is not set on the checkout, because no
sibling workflow in this repo sets it; adding it here alone would be the
first instance of a convention rather than an application of one. Worth
doing across all five workflows in a separate change if wanted.

This does not depend on #44 and shares no file with it. #44 adds
`requirements/scheduled-failures.txt`; this adds
`requirements/tests.txt`. In whichever order they land, the workflow
here picks up the test files #44 brings with it.
Three corrections to the retirement rule, from a closer read of the org.

Ofelia is a RENAME, not a deletion. A netresearch/.github template sync
on 2026-04-19/20 replaced "Cleanup Container Images" with "Container
Retention" at .github/workflows/container-retention.yml, which is alive
and active today; only the old identity's run history is frozen red. The
rule was already right — a moved file mints a new id and the old one
leaves the list — but "the workflow no longer exists" was not, so the
reason now reads "renamed or removed". Template syncs rename workflows
across the fleet, so this is the shape to expect, not deletion.

Keying on id rather than name is load-bearing, and worth stating because
the obvious rule is to match names. An id follows the FILE: a sync that
edits `name:` in place keeps the id, and matching on name would retire a
workflow that is still running on schedule. There is now a test for that
case; matching on name fails it.

The workflow list must be complete or not believed. An absent id means
"retired", so a list short by one page retires live workflows and
silences their real failures — worse than the noise the exclusion
removes. netresearch/.github already has 63 workflows against a 100-item
page. The list is now read to the end and refuses to answer unless the
pages add up to total_count; an empty list for a repo that demonstrably
has scheduled runs is likewise treated as unreadable rather than as
"everything here is retired".

Verified against the live org: baseline unchanged at 8 reported and 2
excluded, with skill-repo-skill / A/B Evals Schedule confirmed `active`
by direct query rather than assumed.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants