Skip to content

Commit ad9d49e

Browse files
committed
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).
1 parent d7bfbb6 commit ad9d49e

7 files changed

Lines changed: 2118 additions & 5 deletions

File tree

.github/workflows/dms.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ env:
1616
HEARTBEAT_INTERVAL: 336 # in hours, 2 weeks
1717
NUMBER_OF_WARNINGS: 2 # number of warnings before final action
1818
ARMED: "false"
19+
CHECK_PUBLIC_ACTIVITY: "false" # opt-in public-activity liveness; see README
1920

2021
on:
2122
schedule:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ __pycache__/
1212
*.py[cod]
1313
*$py.class
1414

15+
# Build artifacts (uv/setuptools)
16+
*.egg-info/
17+
1518
# MacOS
1619
**/.DS_Store
1720

README.md

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,229 @@ already-accrued warnings and could re-fire PASSED_AWAY within a single
274274
cron *without* going through the warning ladder again. Dropping every
275275
bot commit (step 2 above) is the only safe reset.
276276

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+
277284
### Armed vs Test Mode
278285
- **Test Mode (`ARMED=false`):** Sends emails to you only for testing
279286
- **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`.
280287

288+
### Optional: public-GitHub-activity fallback (v2.1+)
289+
290+
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:
308+
https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
309+
- **Where:** GitHub → your avatar → *Settings**Developer
310+
settings**Personal access tokens**Fine-grained tokens*
311+
*Generate new token*.
312+
- **Token name:** anything (e.g. `dead-mans-switch`).
313+
- **Expiration:** **1 year** (use *Custom* if you need exactly
314+
365 days). The feature fails closed when the token expires —
315+
you just lose this signal, no other harm. **Do NOT pick "No
316+
expiration"**: a leaked non-expiring PAT is a forever
317+
credential.
318+
- **Resource owner:** yourself.
319+
- **Repository access:** *Public Repositories (read-only)* is
320+
fine — the endpoint we hit is the public events feed.
321+
- **Permissions:** **leave every checkbox under "Repository
322+
permissions" and "Account permissions" at *No access*.** The
323+
token only proves you are you; the endpoint reads publicly
324+
available data.
325+
- Click **Generate token** and copy the value.
326+
2. Add the token as a **repo secret** named `GH_ACTIVITY_TOKEN`.
327+
- **Where:** repo → *Settings**Secrets and variables*
328+
*Actions**Secrets* tab → *New repository secret*.
329+
- Paste **without trailing whitespace, newlines, or quotes**
330+
the script rejects malformed tokens at startup with a clear
331+
error in the Actions log.
332+
3. Add a **repo variable** named `CHECK_PUBLIC_ACTIVITY` with the
333+
value `true` (lowercase, no quotes, no spaces).
334+
- **Where:** same screen as step 2, but the *Variables* tab.
335+
- Strict-parsed: `"yes"`, `"1"`, `"TRUE"`, `" true "` all fail
336+
loudly at startup.
337+
4. (Optional) Repo variable `GH_USERNAME` — only set this if your
338+
personal GitHub handle differs from the **owner of this repo**
339+
(e.g. if you've transferred the repo into a GitHub
340+
organization). **Triple-check the spelling**: a typo here
341+
silently queries someone else's account, and the switch never
342+
fires. If `GH_USERNAME` differs from `GITHUB_REPOSITORY_OWNER`,
343+
the workflow emits a `::notice::` on **every cron and every
344+
manual dispatch** — so you can verify immediately by running the
345+
workflow manually (the API call itself is short-circuited on
346+
manual dispatch, but the validation notices still fire).
347+
- **Org-owned repos:** you **must** set `GH_USERNAME` to your
348+
personal handle. Otherwise the feature queries the org's
349+
event stream, which is unrelated to your activity.
350+
5. (Optional) Repo variable `BOT_AUTHOR_PATTERNS` — newline-
351+
separated regexes for bot **GitHub logins** beyond the built-in
352+
`[bot]` suffix (so `dependabot[bot]`, `github-actions[bot]`,
353+
etc. are already filtered for free). Example value:
354+
```
355+
^renovate$
356+
^github-actions$
357+
```
358+
6. (Optional) Repo variable `BOT_MESSAGE_PATTERNS` — newline-
359+
separated regexes that match against PR/issue/comment/review/
360+
release text. Each pattern runs against at most 4 KB of text
361+
per field (ReDoS defense). Example value:
362+
```
363+
^chore\(deps\):
364+
^Bump .* from .* to .*$
365+
```
366+
367+
### Verify your setup
368+
369+
After step 6, go to *Actions**Dead Man's Switch**Run
370+
workflow* (leave inputs at defaults). Manual dispatch does NOT call
371+
`api.github.com` (by design — see *Hard limitations* below), but
372+
the constructor still runs every other validation. Look at the run
373+
log for:
374+
375+
- a `::notice::GH_USERNAME ... differs from
376+
GITHUB_REPOSITORY_OWNER ...` line (if you set `GH_USERNAME`),
377+
- a startup failure with `Invalid regex in BOT_AUTHOR_PATTERNS`
378+
(if your regex is malformed),
379+
- a startup failure with `GH_ACTIVITY_TOKEN contains whitespace,
380+
newlines, or non-base64-safe characters` (if you pasted the
381+
token with stray whitespace),
382+
- a startup failure with `CHECK_PUBLIC_ACTIVITY must be 'true',
383+
'false', or empty` (if you set it to `yes`/`1`/`TRUE`).
384+
385+
If the run is green and none of those notices appear, your
386+
configuration is valid — wait for the next scheduled cron (default:
387+
09:00 UTC daily) for the first real events-feed query.
388+
389+
### What gets checked
390+
391+
`PushEvent`, `PullRequestEvent`, `PullRequestReviewEvent`,
392+
`PullRequestReviewCommentEvent`, `IssueCommentEvent`, `IssuesEvent`,
393+
`CreateEvent`, `DeleteEvent`, `ReleaseEvent`, `CommitCommentEvent`,
394+
`GollumEvent`, `MemberEvent`, `PublicEvent`, `DiscussionEvent`,
395+
`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+
281500
## 🔧 Email Setup Guide
282501

283502
### Supported Email Providers
@@ -353,7 +572,7 @@ graph TD
353572

354573
### 🔒 **Maximum Privacy**
355574
- **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.
357576
- **Self-Hosted Option:** Run on your own GitHub Actions runner for ultimate privacy
358577
- **No Data Collection:** We don't see or store anything
359578

0 commit comments

Comments
 (0)