You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: opt-in public-GitHub-activity liveness signal
Adds CHECK_PUBLIC_ACTIVITY (off by default). When enabled and a
heartbeat interval has elapsed, the switch consults the owner's
public activity feed before advancing state — recent non-bot events
on any repo other than this one keep the switch ALIVE for one more
cycle. Implements docs/PLAN_liveness.md.
Security posture:
- Opener installs no HTTPRedirectHandler (auth-leak defense — Python's
default forwards Authorization on cross-origin redirects, unlike
requests/curl).
- Regex inputs capped at 4 KB (ReDoS defense on PR/issue bodies).
BOT_AUTHOR_PATTERNS runs against the bounded 39-char actor.login.
- Per-event errors (KeyError/AttributeError/TypeError/ValueError) are
caught+skipped; the _is_bot_event call is OUTSIDE that try, so
filter-logic bugs propagate (plan §2.5).
- Fail-closed network branch catches URLError (incl. HTTPError),
JSONDecodeError, TimeoutError, AND http.client.HTTPException (which
doesn't subclass URLError — BadStatusLine/IncompleteRead would
otherwise crash on a malformed HTTP response).
- Generic ::warning:: text on fail-closed (no detail leaked as a
timing oracle for token expiry / rate-limit hit).
- Token shape (^[A-Za-z0-9_]+$) validated at startup.
- CHECK_PUBLIC_ACTIVITY strict-parsed at startup (yes/1/TRUE raise).
- All cheap validation (pattern compile, parser, token check,
username typo notice) runs BEFORE _get_remaining_warnings, so a
bad regex fails before any git work (plan §2.10).
State machine:
- ALREADY_DECLARED_DEAD and DISARMED still take precedence — the
override gate runs only if interval has elapsed AND the feature is
enabled AND it's not a manual dispatch.
- Manual dispatch short-circuits the API call entirely (no
rate-limit burn on every "Run workflow" click).
- Heartbeat intervals longer than 30 days are silently clamped to
GitHub's events-feed ceiling with a ::notice::.
- Successful override emits ::notice::Public activity override:
keeping ALIVE (latest event ...).
Tests: +707 LOC in tests/test_liveness.py covering every branch of
_is_bot_event (incl. missing/empty/non-string actor, [bot]-suffix
endswith vs startswith, any-vs-all semantics, each payload path
parametrized), _has_recent_public_activity (all 4 fail-closed
exception types incl. HTTPException, boundary on event_time<since,
filter-bug propagation parametrized over the 4 caught types, null
actor through the integration path, two-cron self-exclusion),
_get_state integration (2×2 truth table + 4 precedence invariants),
_consult_public_activity (clamp arithmetic with actual since-cutoff
verification), _compile_pattern_list, _parse_check_public_activity
(strict reject of yes/1/TRUE/whitespace), _build_opener (no redirect
handler regression test), token shape, GH_USERNAME typo notice, and
_truncate_for_regex prefix preservation.
Also adds tests/test_workflow.py lints for the new
CHECK_PUBLIC_ACTIVITY env default and a regression test that
GH_* / BOT_* names are not in the workflow's RESERVED regex.
100% line + branch coverage maintained. No new third-party deps
(stdlib urllib.request/urllib.error/json/http.client only).
Copy file name to clipboardExpand all lines: README.md
+220-1Lines changed: 220 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -274,10 +274,229 @@ already-accrued warnings and could re-fire PASSED_AWAY within a single
274
274
cron *without* going through the warning ladder again. Dropping every
275
275
bot commit (step 2 above) is the only safe reset.
276
276
277
+
> **Note on the public-activity feature:** if you keep
278
+
> `CHECK_PUBLIC_ACTIVITY=true` after resetting, remember that the
279
+
> override looks back over a period during which you may have been
280
+
> unusually quiet (mourning, hospital, vacation). Don't rely on it
281
+
> as your sole liveness signal in the first heartbeat cycle after
282
+
> reset — push the heartbeat from step 4 promptly.
283
+
277
284
### Armed vs Test Mode
278
285
-**Test Mode (`ARMED=false`):** Sends emails to you only for testing
279
286
-**Armed Mode (`ARMED=true`):** Sends emails to recipients if you are inactive for the amount of time you specify in `HEARTBEAT_INTERVAL` in combination with `NUMBER_OF_WARNINGS`.
Opt-in, off by default. If your commits-to-this-repo cadence is
291
+
patchy but you're active on **other** GitHub repos, you can have
292
+
the switch consult your public activity feed before advancing
293
+
state. See [GitHub activity as fallback liveness signal](#-optional-github-activity-as-fallback-liveness-signal) for setup, limitations, and threat model.
294
+
295
+
## 🔍 Optional: GitHub activity as fallback liveness signal
296
+
297
+
By default the switch only watches commits in *this* repo. If you commit
298
+
frequently to **other** repos but rarely to this one, you can opt in to
299
+
have the switch also check your public GitHub activity before advancing
300
+
state. When enabled, an interval-passed heartbeat is "rescued" for one
301
+
more cycle if your public activity feed shows non-bot events since the
302
+
window started. The feature is **off by default**; turning it on accepts
303
+
the trade-offs documented in the threat model below.
304
+
305
+
### Setup
306
+
307
+
1.**Create a fine-grained Personal Access Token.** GitHub docs:
`DiscussionCommentEvent`. Watching/starring/forking is ignored — too
396
+
passive to count as "still alive".
397
+
398
+
### Recommended interval
399
+
400
+
The GitHub events feed returns at most **30 days** of activity. If
401
+
your `HEARTBEAT_INTERVAL` is ≤ 720h (30 days), the feature can fully
402
+
extend the heartbeat window. If you set a longer interval, the
403
+
feature is silently capped at 30 days — the workflow log emits a
404
+
`::notice::` you'll see in the Actions tab.
405
+
406
+
### Hard limitations
407
+
408
+
-**30-day / 300-event API ceiling.** The events feed returns at
409
+
most 30 days of activity and at most 300 events. Heartbeat
410
+
intervals longer than 30 days are silently clamped to 30 days
411
+
(with a `::notice::`).
412
+
-**30 events per page, no pagination.** The feed reads only the
413
+
most recent 30 events. If you maintain many repos and Dependabot/
414
+
Renovate churn produces >30 bot events in the most recent slice
415
+
of your activity, your human events can fall off the page. Tight
416
+
`BOT_AUTHOR_PATTERNS` won't help (the events are already in the
417
+
30 we received); rely on commit-only liveness in that case.
418
+
-**~5-minute events-feed lag.** GitHub documents the events API as
419
+
eventually consistent. A cron firing minutes after a real commit
420
+
elsewhere may still see an empty feed and advance state. One
421
+
missed cycle costs one warning commit; recovery is automatic on
422
+
the next cron.
423
+
-**Org-owned repos.**`GITHUB_REPOSITORY_OWNER` is the **org**
424
+
login, not yours. **You must set `GH_USERNAME`** to your personal
425
+
handle, otherwise the feature queries the org's activity stream
426
+
(which is usually near-empty).
427
+
-**GitHub Enterprise Server is unsupported.**`api.github.com` is
428
+
hardcoded; GHES would require routing to `${GITHUB_API_URL}`.
429
+
-**Account renamed?** Update `GH_USERNAME` immediately. A rename
430
+
without an update silently degrades to commit-only liveness — the
431
+
old handle 404s, fail-closed fires, no email alert.
432
+
-**Bot filtering uses actor identity only.** As of GitHub's
433
+
2025-10-07 API change, PushEvents no longer expose per-commit
434
+
author/message data — message-pattern filtering only meaningfully
435
+
applies to PR/issue/comment/review/release events.
436
+
-**Backup mirrors with auto-pushing `dms_bot` are unsupported.**
437
+
See the *Threat model addition* below.
438
+
-**Self-hosted runners with corporate TLS interception:** if your
439
+
runner uses a custom CA bundle, set `SSL_CERT_FILE` accordingly.
440
+
The script does not disable TLS verification.
441
+
-**Manual dispatch does NOT call the API** (state-safe verification
442
+
only — no rate-limit burn on every "test" click). All *startup*
443
+
validation still runs, so manual dispatch is the right way to
444
+
verify your variables before the first scheduled cron — see
445
+
*Verify your setup* above.
446
+
-**Long override streaks hide SMTP credential rot.** When the
447
+
override rescues the switch every cycle, the warning handler's
448
+
pre-flight SMTP login never runs. A rotated App Password or
449
+
revoked 2FA only surfaces at PASSED_AWAY. If you've been on an
450
+
override streak for a long time, manually run the workflow with
451
+
`armed=false` to exercise the test-mode SMTP path.
452
+
-**Failure mode:** if the API is unreachable or your token expires,
453
+
the switch silently falls back to commit-only liveness. **You are
454
+
responsible for rotating the PAT before it expires** — there are no
455
+
email reminders.
456
+
457
+
### Privacy notice
458
+
459
+
Enabling this feature makes the workflow query `api.github.com` for
460
+
your public activity (already world-readable data). Two practical
461
+
notes:
462
+
463
+
- The PAT lives in your repo secrets — keep the repo private.
464
+
-**Self-hosted runners**: the PAT in your `Authorization` header
465
+
reaches GitHub from your runner's IP. GitHub-side logs will link
466
+
your runner IP to your token. For maximum privacy, leave
467
+
`CHECK_PUBLIC_ACTIVITY=false` and rely on commit-only liveness.
468
+
469
+
### Threat model addition
470
+
471
+
This feature **makes the switch rely on GitHub's API being right
472
+
about you.** If GitHub's events feed says you were active when you
473
+
weren't (API bug, MITM on the runner's egress, account compromise),
474
+
the switch treats you as alive and never fires. Treat this feature
475
+
as **defense in depth** — it can keep you alive when you've been
476
+
quiet in *this* repo but active elsewhere; it should not be your
477
+
sole liveness signal.
478
+
479
+
Your `BOT_AUTHOR_PATTERNS` / `BOT_MESSAGE_PATTERNS` are also part of
480
+
the security perimeter. A too-permissive filter could classify your
481
+
own human activity as bot noise and let the switch fire while you're
482
+
alive. A too-restrictive filter could let a Dependabot PR merge mask
483
+
real inactivity. **Defaults lean toward over-firing**: if in doubt,
484
+
leave the patterns empty.
485
+
486
+
**Backup mirrors are NOT supported by the existing filter knobs.**
487
+
The built-in self-exclusion only covers the current repo
488
+
(`$GITHUB_REPOSITORY`). If you maintain a backup mirror where
489
+
`dms_bot` auto-pushes the same warning commits, the mirror's
490
+
PushEvents look identical to genuine activity on the events feed —
491
+
they're tagged with **your** GitHub username (`actor.login`), not
492
+
with the commit author. `BOT_AUTHOR_PATTERNS` matches the GitHub
493
+
username (the events feed exposes no commit-author info beyond the
494
+
2025-10-07 PushEvent change), so adding `dms_bot` to it has no
495
+
effect. If you run a backup mirror, either keep
496
+
`CHECK_PUBLIC_ACTIVITY=false`, or accept that the override may rescue
497
+
the switch from the mirror's auto-push rather than from genuine
498
+
activity.
499
+
281
500
## 🔧 Email Setup Guide
282
501
283
502
### Supported Email Providers
@@ -353,7 +572,7 @@ graph TD
353
572
354
573
### 🔒 **Maximum Privacy**
355
574
-**Private Repository:** Your configuration stays confidential
356
-
-**No External Services:** Everything runs on GitHub's infrastructure
575
+
-**No External Services (by default):** Everything runs on GitHub's infrastructure. The one exception is the opt-in public-activity feature — when `CHECK_PUBLIC_ACTIVITY=true`, the workflow makes a single authenticated GET to `api.github.com/users/<you>/events/public` per run.
357
576
-**Self-Hosted Option:** Run on your own GitHub Actions runner for ultimate privacy
358
577
-**No Data Collection:** We don't see or store anything
0 commit comments