My GitHub Commits Write My X Posts: Claude + GitHub Actions Build-in-Public Automation
- automation
- GitHub Actions
- Claude
- X
- build in public
- LLM
Building in public has a tax: after a day of shipping, writing the post about it is the last thing you want to do. So I automated mine — not with canned templates, but with a pipeline that reads what I actually did and writes the way I actually write. It's been posting to @sshahzaiib twice a day, and the interesting parts are all in the guardrails.
The pipeline
A GitHub Actions workflow fires at 09:00 and 17:00 UTC (plus a workflow_dispatch with dry_run defaulting to true — the only sane default for anything that posts publicly):
- Fetch reality. A Python script pulls my repos via the GitHub API with a fine-grained PAT and collects my commits from the last two days into
activity.json— merge commits excluded, authorship matched. The PAT matters: the default Actions token only sees the current repo, and most of my work happens in private ones. - Draft.
anthropics/claude-code-action@v1(authenticated with a Claude Max OAuth token, not API credits) readsactivity.jsonplus two documents —prompt.md(the rules) andstyle.md(the voice) — and writespost.txt. - Gate. If the draft is missing or says
SKIP, the workflow stops. The LLM has an explicit "nothing worth saying" exit — which is most of why the output stays credible. - Post. A script splits threads on
---(max 3 tweets), enforces 280 chars each, and posts via the X API v2 with OAuth 1.0a.
Memory, or the bot repeats itself
LLMs run statelessly, and a stateless poster converges on the same three openers within a week. The fix is history.json: every published post is appended (rolling window of 15) and committed back to the repo by the workflow itself, then fed into the next prompt with rules — don't reuse opening lines or sentence structures; don't re-post about a repo without new progress; rotate between six post formats (A–F) and never repeat the last two; the "connect" format at most weekly.
Two bugs from this loop earned their scars:
- The push failed silently. Runners don't persist credentials for pushing; the fix is pushing to an explicitly authenticated URL:
https://x-access-token:${GH_TOKEN}@github.com/....git. - The bot discovered itself. The history commit is a commit — so the next run found "activity" in the automation repo and tried to post about its own posting.
EXCLUDE_REPOS="cowork-automations"in the fetcher. Every self-modifying automation needs the don't-eat-your-own-tail filter; you just don't know it until it eats one.
The economics nobody mentions
X API pricing has a quirk: a post containing a URL costs roughly $0.20 versus ~$0.015 for plain text — over 10× more. So prompt.md simply instructs: no links. The constraint turned out to be good for the content — link-free posts read as thoughts rather than promotions, and the engagement matches. A pricing constraint accidentally enforced a writing principle.
Where the rules drifted
The system's evolution is a small case study in tending automated voice. It started posting every other day, skipping when there was no activity; cadence moved to twice daily, and the skip-on-quiet rule became a fallback — on no-commit days it writes an engagement post (a question, a hot take, behind-the-scenes) about North instead of going silent. Thread support arrived when single posts got cramped. Each change was a one-line edit to a markdown file, which is the underrated property of prompt-as-config: the editorial policy is version-controlled, reviewable, and diffable like everything else.
Does it feel like cheating?
The posts are generated; the activity is real — every one is grounded in commits I actually pushed that day, in a voice document I wrote, through rules I tuned post by post. It's closer to having an editor than a ghostwriter. And it's recursive in a way I enjoy: the first thing the system ever posted about was me building the system — and some of those posts became the seeds for articles on this blog.