想做什么

GitHub 个人主页那张 contribution 图,空一两天没事,但要是天天都是白的,看着总觉得差点意思。我又不想为了填格子去硬造「有意义」的提交——那样更没劲。

所以就有了这个:一个靠 GitHub Actions 定时跑的小东西,自动往一个专用私有仓库里随机提交几次。提交内容没有任何实际意义,只往一个日志文件里追加一行时间戳,也不碰任何真实项目。

这个东西前后折腾了两天,从最初 20 行的 workflow 文件,一路长成了带桌面面板、邮件通知、还能打包成 exe 发给朋友的小软件。这篇文章把它整个记下来——包括每一个踩过的坑。

第一版:一个 yml 文件

最初的核心就一个文件:.github/workflows/daily.yml。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
on:
schedule:
- cron: "30 12 * * *" # UTC 12:30 = 北京时间 20:30
workflow_dispatch: {} # 手动触发,方便测试

jobs:
commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
git config user.name "nbsheep"
git config user.email "118652964+nbsheep@users.noreply.github.com"
- run: |
N=$(( RANDOM % 4 + 1 ))
for i in $(seq 1 $N); do
echo "$(date -u '+%FT%TZ') commit #$i" >> activity.log
git add activity.log
git commit -m "chore: daily activity #$i"
done
- run: git push

为什么放云端跑而不是本机计划任务:电脑得开机、令牌得存本机,重装个系统就断了。GitHub Actions 跑在它自家服务器上,只要仓库在它就跑,跟我的电脑没有任何关系。

这个版本能用,当天确实绿了。但用了没两天就不满足了。

用着用着就开始不爽

不爽的点很具体:

  1. 想改提交时间,得改 yml 里的 cron、commit、push 一整趟。 cron 是 UTC 时间,每次都得心算加八小时;
  2. GitHub 的调度不靠谱,说好 20:30 跑,实际经常拖到九十点;
  3. 想让某天不提交(比如我在外面玩),没有开关。

于是改了设计:提交时间不再写死在 yml 里,而是放在仓库根目录的 config.json,workflow 改成每 30 分钟醒来检查一次「现在到没到配置的时间点」,到了才提交。

1
2
3
4
5
6
7
8
on:
schedule:
- cron: "0,30 * * * *" # 每半小时自检一次
workflow_dispatch: {} # 手动触发 = 无条件立即提交一批

concurrency:
group: daily-commits
cancel-in-progress: false # 排队串行,防止两轮检查撞车重复提交

config.json 长这样:

1
2
3
4
5
6
7
{
"enabled": true,
"default_times": ["20:30"],
"overrides": { "2026-10-01": [] },
"count_range": [1, 4],
"executed": { "2026-08-27": ["20:30"] }
}

几个字段的含义:default_times 是每天的默认时间点;overrides 可以单独指定某天(设成 [] 就是当天不提交);count_range 是每个时间点随机提交几次;executed 是脚本自动维护的状态,记录当天哪些时间点已经执行过,防止同一时间点被反复执行。

还有个细节值得说:脚本只在一轮推送成功之后才把时间点记进 executed。这样哪怕某轮跑挂了,半小时后的下一轮会自动补上,不会出现「到点了但没提交、还以为提交过了」的洞。

从此改时间就是改一个 JSON 文件的事。但很快,连 JSON 都懒得改了——这是后话。

踩坑实录

坑一:提交不进贡献图,99% 是邮箱问题。 GitHub 只统计「账号关联邮箱」的提交。git 配置里的真实邮箱如果没在 GitHub 验证过,提交了也白提。解法是用 noreply 邮箱,格式形如 123456+username@users.noreply.github.com,在 Settings → Emails 页面顶部能看到。它永远有效,还不暴露真实邮箱。

坑二:push workflow 文件被拒。 手动生成的 Personal Access Token 推含 .github/workflows/ 的提交时,必须额外勾 workflow scope,否则被拒。当时卡了一会儿,重新授权才推上去。

坑三:私有仓库的绿点默认不显示。 得去 profile 设置里打开 “Include private contributions on my profile”。

坑四:GitHub 的 cron 是「尽力而为」。 文档写得客气,实际体验就是延迟半小时起步,高峰期一两个小时也正常。有天凌晨我收到一条 Actions 运行通知,一看时间 01:43——这就是白天某轮被挤掉的检查补跑的,跑完发现没到时间点,什么也没做就结束了。所以别指望分秒不差,这套设计里「每 30 分钟检查一次」本身就是为了吸收这种延迟。

坑五(最隐蔽):子进程污染了 stdout。 加邮件通知的时候,workflow 里用一行 shell 判断「这轮真的提交了吗」:

1
2
3
4
5
OUT=$(python3 scripts/auto_commit.py)
case "$OUT" in
made*) echo "committed=true" >> "$GITHUB_OUTPUT" ;;
*) echo "committed=false" >> "$GITHUB_OUTPUT" ;;
esac

脚本自己只在真提交时才打印 made N commit(s),理论上没问题。但 Python 脚本内部用 subprocess 调 git,子进程默认继承 stdout 管道,于是 git 每次 commit 打的 [main 4ce3456] chore: ... 全混进了 OUT 开头,脚本自己那句 made ... 被挤到了中间。case 是按开头匹配的,结果就是:提交明明成功了,邮件永远静默。查了半天日志,才发现 OUT 开头全是方括号。

修法就一行——把 git 的输出甩去 stderr,stdout 只留协议内容:

1
subprocess.run(["git", *args], check=True, stdout=sys.stderr)

教训:当你拿一个进程的 stdout 当通信协议时,它调用的所有子进程都得闭嘴。

坑六:免费额度没想象中耐用。 私有仓库的 Actions 有免费分钟数(每月 2000 分钟),每次运行按分钟向上取整计费。每 30 分钟一跑,一次几十秒也按 1 分钟算:48 次/天 ≈ 1500 分钟/月,居然吃掉四分之三。仓库转 public 就不计时了,或者把间隔拉长。目前我留着 30 分钟,快到限额再说。

干脆做成一个桌面小软件

到了这一步,改个时间点还是得:开网页 → 找到仓库 → 编辑 JSON → commit。一个「改个闹钟」的操作要五步,忍不了。

于是写了 dashboard/server.py:一个只监听 127.0.0.1:8630 的本地小服务,纯 Python 标准库,零第三方依赖,配一个单文件网页界面,再用 Edge 的 --app 模式包成无地址栏的独立窗口——看起来就跟个原生桌面软件差不多。

主界面总览

登录支持三种方式:粘贴 Personal Access Token、导入本机 gh CLI 的登录、还有最舒服的设备配对码——面板显示一个码,去 github.com/login/device 输进去授权,完事。

登录界面

浏览器配对

Token 的存放走了 Windows DPAPI 加密。这里踩了个玄学坑:我一开始用 ctypes 直接调 crypt32 的 CryptProtectData,结果这台机器的 Anaconda Python 一调就整个进程静默消失——没有 traceback,没有报错,stdout 什么都不剩。排查半天换成 PowerShell 的 ConvertTo-SecureString(底层同样是 DPAPI、同样绑定当前用户),一次就稳了。有时候绕过舒适区不如站在舒适区边上。

另一个本地特色问题:直连 api.github.com 时,Python 的 TLS 握手总是被重置,curl 却能通——什么原因懂的都懂。面板里做了个自动回退:直连失败就依次试本机常见的代理端口(7897、7890 这些),哪条通就记住哪条,之后 REST 调用和 gh 子进程全走那条路。

面板能做的事,就是把前面所有的手动操作变成按钮:

时间设置

运行记录

改时间点、指定某天不提交、调整随机次数、开关机自启、手动触发一批、看每次云端运行的状态和耗时。顺便说一句,那个「立即提交一次」按钮走的是 workflow_dispatch,会绕过时间表直接强制提交,测试配置改动特别顺手。

邮件通知:只在真提交了才吵你

通知的设计原则只有一条:例行检查不发信,只有真的执行了提交(不管成功失败)才发。不然一天 48 次检查,邮箱先炸。

这就是坑五里那套 committed=true/false 协议的用途——workflow 里 commit job 把结果写进 job output,notify job 挂着 if: needs.commit.outputs.committed == 'true' 的条件,只有真提交了才运行,去读仓库 Secrets 里的 SMTP 配置发邮件。

绑定在面板上完成:填收件邮箱、选服务商、粘贴 SMTP 授权码,面板把它们加密写进仓库的 Actions Secrets,明文任何地方都不显示、不回显。

邮件通知绑定

以 QQ 邮箱为例:网页版设置 → 账号 → 开启 SMTP 服务(要发短信验证)→ 拿到 16 位授权码。注意那不是 QQ 密码,是专门给第三方客户端用的。绑定后点一下「立即提交一次」,一两分钟内就能收到第一封结果邮件,没收到先翻垃圾箱。

打包成 exe,发给朋友

做到这里,这东西已经不太像「我的脚本」,而像个「软件」了。朋友看了说她也想要一个,于是用 PyInstaller 打成了单文件 exe,双击就跑,不用装 Python。

打包本身没什么难度,两个坑值得记:

一是 onefile 模式下资源路径会变——运行时所有文件被解压到一个临时目录(sys._MEIPASS),代码里所有「读当前目录下的文件」都得改成从那里读,否则一启动就找不到自己的网页文件。

二是朋友的机器上没有 gh CLI,写 Secrets 不能再偷懒调命令行,老老实实走 REST API。而 Secrets 写入接口要求用 libsodium 的 sealed-box 加密后提交:先从仓库拿公钥,本地加密,再上传密文。PyNaCl 一个依赖搞定,塞进 exe 里正好。

exe 是通用版:登录谁的账号就管谁的仓库。首次进入时如果没有 <用户名>/daily-commits 这个仓库,面板顶部会出现一条黄色横幅,点「一键创建并部署云端」,它会自动建好私有仓库,把 workflow、脚本、默认配置全部上传,十几秒后就能直接用,全程不碰命令行。

首次设置横幅

一点实话

这玩意儿填出来的格子,跟「你干了活」是两码事。它就是个装饰,图个自己看着顺眼。真要拿 profile 给谁看,还是得靠那些真实项目里的提交——那个谁都糊弄不了,也没必要糊弄。

不过回头看,为了这几块绿格子顺手学到的东西倒是实打实的:GitHub 的 device flow 登录、DPAPI、libsodium sealed box、PyInstaller 的冻结路径、还有「stdout 是协议通道就别让子进程往里吐」这种一辈子忘不掉的教训。格子是假的,这些是真的。

最初那版 20 行的简单方案我开源了,想抄的直接拿去改:github-auto-commits。带面板的完整版放在私有仓库里——毕竟这个仓库本身就是个替身,把它开源了总感觉有点套娃。文章里该讲的思路都在,想复刻一个不难。